This commit is contained in:
2025-12-04 23:31:55 +01:00
parent 9fa21a488a
commit fb509607c1
96 changed files with 5839 additions and 1609 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, use } from 'react';
import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation';
import { getDriverRepository } from '@/lib/di-container';
@@ -14,7 +14,7 @@ import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
export default function DriverDetailPage({
searchParams,
}: {
searchParams?: { [key: string]: string | string[] | undefined };
searchParams: any;
}) {
const router = useRouter();
const params = useParams();
@@ -24,14 +24,36 @@ export default function DriverDetailPage({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const unwrappedSearchParams = use(searchParams) as URLSearchParams | undefined;
const from =
typeof searchParams?.from === 'string' ? searchParams.from : undefined;
const leagueId =
typeof searchParams?.leagueId === 'string'
? searchParams.leagueId
typeof unwrappedSearchParams?.get === 'function'
? unwrappedSearchParams.get('from') ?? undefined
: undefined;
const backLink =
from === 'league' && leagueId ? `/leagues/${leagueId}` : null;
const leagueId =
typeof unwrappedSearchParams?.get === 'function'
? unwrappedSearchParams.get('leagueId') ?? undefined
: undefined;
const raceId =
typeof unwrappedSearchParams?.get === 'function'
? unwrappedSearchParams.get('raceId') ?? undefined
: undefined;
let backLink: string | null = null;
if (from === 'league-standings' && leagueId) {
backLink = `/leagues/${leagueId}/standings`;
} else if (from === 'league' && leagueId) {
backLink = `/leagues/${leagueId}`;
} else if (from === 'league-members' && leagueId) {
backLink = `/leagues/${leagueId}`;
} else if (from === 'league-race' && leagueId && raceId) {
backLink = `/leagues/${leagueId}/races/${raceId}`;
} else {
backLink = null;
}
useEffect(() => {
loadDriver();
@@ -119,7 +141,7 @@ export default function DriverDetailPage({
/>
{/* Driver Profile Component */}
<DriverProfile driver={driver} />
<DriverProfile driver={driver} isOwnProfile={false} />
</div>
</div>
);

View File

@@ -1,127 +1,101 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import DriverCard from '@/components/drivers/DriverCard';
import RankBadge from '@/components/drivers/RankBadge';
import Input from '@/components/ui/Input';
import Card from '@/components/ui/Card';
import { getDriverAvatarUrl } from '@/lib/racingLegacyFacade';
import { getDriverRepository, getDriverStats, getAllDriverRankings } from '@/lib/di-container';
// Mock data (fictional demo drivers only)
const MOCK_DRIVERS = [
{
id: 'driver-1',
name: 'Alex Vermeer',
rating: 3245,
skillLevel: 'pro' as const,
nationality: 'Netherlands',
racesCompleted: 156,
wins: 45,
podiums: 89,
isActive: true,
rank: 1,
},
{
id: 'driver-2',
name: 'Liam Hartmann',
rating: 3198,
skillLevel: 'pro' as const,
nationality: 'United Kingdom',
racesCompleted: 234,
wins: 78,
podiums: 145,
isActive: true,
rank: 2,
},
{
id: 'driver-3',
name: 'Michael Schmidt',
rating: 2912,
skillLevel: 'advanced' as const,
nationality: 'Germany',
racesCompleted: 145,
wins: 34,
podiums: 67,
isActive: true,
rank: 3,
},
{
id: 'driver-4',
name: 'Emma Thompson',
rating: 2789,
skillLevel: 'advanced' as const,
nationality: 'Australia',
racesCompleted: 112,
wins: 23,
podiums: 56,
isActive: true,
rank: 5,
},
{
id: 'driver-5',
name: 'Sarah Chen',
rating: 2456,
skillLevel: 'advanced' as const,
nationality: 'Singapore',
racesCompleted: 89,
wins: 12,
podiums: 34,
isActive: true,
rank: 8,
},
{
id: 'driver-6',
name: 'Isabella Rossi',
rating: 2145,
skillLevel: 'intermediate' as const,
nationality: 'Italy',
racesCompleted: 67,
wins: 8,
podiums: 23,
isActive: true,
rank: 12,
},
{
id: 'driver-7',
name: 'Carlos Rodriguez',
rating: 1876,
skillLevel: 'intermediate' as const,
nationality: 'Spain',
racesCompleted: 45,
wins: 3,
podiums: 12,
isActive: false,
rank: 18,
},
{
id: 'driver-8',
name: 'Yuki Tanaka',
rating: 1234,
skillLevel: 'beginner' as const,
nationality: 'Japan',
racesCompleted: 12,
wins: 0,
podiums: 2,
isActive: true,
rank: 45,
},
];
type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro';
type DriverListItem = {
id: string;
name: string;
rating: number;
skillLevel: SkillLevel;
nationality: string;
racesCompleted: number;
wins: number;
podiums: number;
isActive: boolean;
rank: number;
};
export default function DriversPage() {
const router = useRouter();
const [drivers, setDrivers] = useState<DriverListItem[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedSkill, setSelectedSkill] = useState('all');
const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all');
const [selectedNationality, setSelectedNationality] = useState('all');
const [activeOnly, setActiveOnly] = useState(false);
const [sortBy, setSortBy] = useState<'rank' | 'rating' | 'wins' | 'podiums'>('rank');
useEffect(() => {
const load = async () => {
const driverRepo = getDriverRepository();
const allDrivers = await driverRepo.findAll();
const rankings = getAllDriverRankings();
const items: DriverListItem[] = allDrivers.map((driver) => {
const stats = getDriverStats(driver.id);
const rating = stats?.rating ?? 0;
const wins = stats?.wins ?? 0;
const podiums = stats?.podiums ?? 0;
const totalRaces = stats?.totalRaces ?? 0;
let effectiveRank = Number.POSITIVE_INFINITY;
if (typeof stats?.overallRank === 'number' && stats.overallRank > 0) {
effectiveRank = stats.overallRank;
} else {
const indexInGlobal = rankings.findIndex(
(entry) => entry.driverId === driver.id,
);
if (indexInGlobal !== -1) {
effectiveRank = indexInGlobal + 1;
}
}
const skillLevel: SkillLevel =
rating >= 3000
? 'pro'
: rating >= 2500
? 'advanced'
: rating >= 1800
? 'intermediate'
: 'beginner';
const isActive = rankings.some((r) => r.driverId === driver.id);
return {
id: driver.id,
name: driver.name,
rating,
skillLevel,
nationality: driver.country,
racesCompleted: totalRaces,
wins,
podiums,
isActive,
rank: effectiveRank,
};
});
setDrivers(items);
setLoading(false);
};
void load();
}, []);
const nationalities = Array.from(
new Set(MOCK_DRIVERS.map((d) => d.nationality).filter(Boolean))
new Set(drivers.map((d) => d.nationality).filter(Boolean)),
).sort();
const filteredDrivers = MOCK_DRIVERS.filter((driver) => {
const filteredDrivers = drivers.filter((driver) => {
const matchesSearch = driver.name
.toLowerCase()
.includes(searchQuery.toLowerCase());
@@ -135,9 +109,12 @@ export default function DriversPage() {
});
const sortedDrivers = [...filteredDrivers].sort((a, b) => {
const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY;
const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY;
switch (sortBy) {
case 'rank':
return a.rank - b.rank;
return rankA - rankB || b.rating - a.rating || a.name.localeCompare(b.name);
case 'rating':
return b.rating - a.rating;
case 'wins':
@@ -153,6 +130,14 @@ export default function DriversPage() {
router.push(`/drivers/${driverId}`);
};
if (loading) {
return (
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading drivers...</div>
</div>
);
}
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
@@ -183,7 +168,7 @@ export default function DriversPage() {
<select
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={selectedSkill}
onChange={(e) => setSelectedSkill(e.target.value)}
onChange={(e) => setSelectedSkill(e.target.value as SkillLevel | 'all')}
>
<option value="all">All Levels</option>
<option value="beginner">Beginner</option>
@@ -251,56 +236,20 @@ export default function DriversPage() {
</div>
<div className="space-y-4">
{sortedDrivers.map((driver, index) => (
<Card
{sortedDrivers.map((driver) => (
<DriverCard
key={driver.id}
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
id={driver.id}
name={driver.name}
rating={driver.rating}
skillLevel={driver.skillLevel}
nationality={driver.nationality}
racesCompleted={driver.racesCompleted}
wins={driver.wins}
podiums={driver.podiums}
rank={driver.rank}
onClick={() => handleDriverClick(driver.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<RankBadge rank={driver.rank} size="lg" />
<div className="w-16 h-16 rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center">
<Image
src={getDriverAvatarUrl(driver.id)}
alt={driver.name}
width={64}
height={64}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-white mb-1">{driver.name}</h3>
<p className="text-sm text-gray-400">
{driver.nationality} {driver.racesCompleted} races
</p>
</div>
</div>
<div className="flex items-center gap-8 text-center">
<div>
<div className="text-2xl font-bold text-primary-blue">{driver.rating}</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
<div>
<div className="text-2xl font-bold text-green-400">{driver.wins}</div>
<div className="text-xs text-gray-400">Wins</div>
</div>
<div>
<div className="text-2xl font-bold text-warning-amber">{driver.podiums}</div>
<div className="text-xs text-gray-400">Podiums</div>
</div>
<div>
<div className="text-sm text-gray-400">
{((driver.wins / driver.racesCompleted) * 100).toFixed(0)}%
</div>
<div className="text-xs text-gray-500">Win Rate</div>
</div>
</div>
</div>
</Card>
/>
))}
</div>

View File

@@ -6,6 +6,7 @@ import { getAuthService } from '@/lib/auth';
import { AlphaNav } from '@/components/alpha/AlphaNav';
import AlphaBanner from '@/components/alpha/AlphaBanner';
import AlphaFooter from '@/components/alpha/AlphaFooter';
import { AuthProvider } from '@/lib/auth/AuthContext';
export const dynamic = 'force-dynamic';
@@ -46,7 +47,6 @@ export default async function RootLayout({
if (mode === 'alpha') {
const authService = getAuthService();
const session = await authService.getCurrentSession();
const isAuthenticated = !!session;
return (
<html lang="en" className="scroll-smooth overflow-x-hidden">
@@ -54,12 +54,14 @@ export default async function RootLayout({
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body className="antialiased overflow-x-hidden min-h-screen bg-deep-graphite flex flex-col">
<AlphaNav isAuthenticated={isAuthenticated} />
<AlphaBanner />
<main className="flex-1 max-w-7xl mx-auto px-6 py-8 w-full">
{children}
</main>
<AlphaFooter />
<AuthProvider initialSession={session}>
<AlphaNav />
<AlphaBanner />
<main className="flex-1 max-w-7xl mx-auto px-6 py-8 w-full">
{children}
</main>
<AlphaFooter />
</AuthProvider>
</body>
</html>
);

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { useState, useEffect, useMemo } from 'react';
import { useRouter, useParams, useSearchParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
@@ -9,11 +9,23 @@ import LeagueMembers from '@/components/leagues/LeagueMembers';
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
import LeagueAdmin from '@/components/leagues/LeagueAdmin';
import StandingsTable from '@/components/leagues/StandingsTable';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import { League } from '@gridpilot/racing/domain/entities/League';
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { getLeagueRepository, getRaceRepository, getDriverRepository, getStandingRepository } from '@/lib/di-container';
import { getMembership, isOwnerOrAdmin, getCurrentDriverId } from '@/lib/racingLegacyFacade';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import {
getLeagueRepository,
getRaceRepository,
getDriverRepository,
getGetLeagueDriverSeasonStatsQuery,
getDriverStats,
getAllDriverRankings,
} from '@/lib/di-container';
import { getMembership, getLeagueMembers, isOwnerOrAdmin } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
export default function LeagueDetailPage() {
const router = useRouter();
@@ -22,16 +34,17 @@ export default function LeagueDetailPage() {
const [league, setLeague] = useState<League | null>(null);
const [owner, setOwner] = useState<Driver | null>(null);
const [standings, setStandings] = useState<Standing[]>([]);
const [drivers, setDrivers] = useState<Driver[]>([]);
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'overview' | 'schedule' | 'standings' | 'members' | 'admin'>('overview');
const [activeTab, setActiveTab] = useState<'overview' | 'schedule' | 'standings' | 'admin'>('overview');
const [refreshKey, setRefreshKey] = useState(0);
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
const membership = getMembership(leagueId, currentDriverId);
const isAdmin = isOwnerOrAdmin(leagueId, currentDriverId);
const searchParams = useSearchParams();
const loadLeagueData = async () => {
try {
@@ -53,15 +66,18 @@ export default function LeagueDetailPage() {
const ownerData = await driverRepo.findById(leagueData.ownerId);
setOwner(ownerData);
// Load standings
const standingRepo = getStandingRepository();
const allStandings = await standingRepo.findAll();
const leagueStandings = allStandings.filter(s => s.leagueId === leagueId);
// Load standings via rich season stats query
const getLeagueDriverSeasonStatsQuery = getGetLeagueDriverSeasonStatsQuery();
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
setStandings(leagueStandings);
// Load all drivers for standings
// Load all drivers for standings and map to DTOs for UI components
const allDrivers = await driverRepo.findAll();
setDrivers(allDrivers);
const driverDtos: DriverDTO[] = allDrivers
.map((driver) => EntityMappers.toDriverDTO(driver))
.filter((dto): dto is DriverDTO => dto !== null);
setDrivers(driverDtos);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load league');
} finally {
@@ -74,34 +90,95 @@ export default function LeagueDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leagueId]);
if (loading) {
return (
<div className="text-center text-gray-400">Loading league...</div>
);
}
useEffect(() => {
const initialTab = searchParams?.get('tab');
if (!initialTab) {
return;
}
if (error || !league) {
return (
<Card className="text-center py-12">
<div className="text-warning-amber mb-4">
{error || 'League not found'}
</div>
<Button
variant="secondary"
onClick={() => router.push('/leagues')}
>
Back to Leagues
</Button>
</Card>
);
}
if (
initialTab === 'overview' ||
initialTab === 'schedule' ||
initialTab === 'standings' ||
initialTab === 'admin'
) {
setActiveTab(initialTab);
}
}, [searchParams]);
const handleMembershipChange = () => {
setRefreshKey(prev => prev + 1);
loadLeagueData();
};
return (
const driversById = useMemo(() => {
const map: Record<string, DriverDTO> = {};
for (const d of drivers) {
map[d.id] = d;
}
return map;
}, [drivers]);
const leagueMemberships = getLeagueMembers(leagueId);
const ownerMembership = leagueMemberships.find((m) => m.role === 'owner') ?? null;
const adminMemberships = leagueMemberships.filter((m) => m.role === 'admin');
const buildDriverSummary = (driverId: string) => {
const driverDto = driversById[driverId];
if (!driverDto) {
return null;
}
const stats = getDriverStats(driverDto.id);
const allRankings = getAllDriverRankings();
let rating: number | null = stats?.rating ?? null;
let rank: number | null = null;
if (stats) {
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
rank = stats.overallRank;
} else {
const indexInGlobal = allRankings.findIndex(
(stat) => stat.driverId === stats.driverId,
);
if (indexInGlobal !== -1) {
rank = indexInGlobal + 1;
}
}
if (rating === null) {
const globalEntry = allRankings.find(
(stat) => stat.driverId === stats.driverId,
);
if (globalEntry) {
rating = globalEntry.rating;
}
}
}
return {
driver: driverDto,
rating,
rank,
};
};
return loading ? (
<div className="text-center text-gray-400">Loading league...</div>
) : error || !league ? (
<Card className="text-center py-12">
<div className="text-warning-amber mb-4">
{error || 'League not found'}
</div>
<Button
variant="secondary"
onClick={() => router.push('/leagues')}
>
Back to Leagues
</Button>
</Card>
) : (
<>
{/* Action Card */}
{!membership && (
@@ -154,16 +231,6 @@ export default function LeagueDetailPage() {
>
Standings
</button>
<button
onClick={() => setActiveTab('members')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'members'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Members
</button>
{isAdmin && (
<button
onClick={() => setActiveTab('admin')}
@@ -187,11 +254,6 @@ export default function LeagueDetailPage() {
<h2 className="text-xl font-semibold text-white mb-4">League Information</h2>
<div className="space-y-4">
<div>
<label className="text-sm text-gray-500">Owner</label>
<p className="text-white">{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}</p>
</div>
<div>
<label className="text-sm text-gray-500">Created</label>
<p className="text-white">
@@ -223,6 +285,107 @@ export default function LeagueDetailPage() {
</div>
</div>
</div>
{league.socialLinks && (
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">Community & Social</h3>
<div className="flex flex-wrap gap-3 text-sm">
{league.socialLinks.discordUrl && (
<a
href={league.socialLinks.discordUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-3 py-1 text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
<span>Discord</span>
</a>
)}
{league.socialLinks.youtubeUrl && (
<a
href={league.socialLinks.youtubeUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-3 py-1 text-red-400 hover:bg-red-500/20 transition-colors"
>
<span>YouTube</span>
</a>
)}
{league.socialLinks.websiteUrl && (
<a
href={league.socialLinks.websiteUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-3 py-1 text-gray-100 hover:bg-iron-gray transition-colors"
>
<span>Website</span>
</a>
)}
</div>
</div>
)}
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">Management</h3>
<div className="space-y-4">
{ownerMembership && (
<div>
<label className="text-sm text-gray-500 block mb-2">Owner</label>
{buildDriverSummary(ownerMembership.driverId) ? (
<DriverSummaryPill
driver={buildDriverSummary(ownerMembership.driverId)!.driver}
rating={buildDriverSummary(ownerMembership.driverId)!.rating}
rank={buildDriverSummary(ownerMembership.driverId)!.rank}
/>
) : (
<p className="text-sm text-gray-500">
{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}
</p>
)}
</div>
)}
{adminMemberships.length > 0 && (
<div>
<label className="text-sm text-gray-500 block mb-2">Admins</label>
<div className="space-y-2">
{adminMemberships.map((membership) => {
const driverDto = driversById[membership.driverId];
const summary = buildDriverSummary(membership.driverId);
const meta =
summary && summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: null;
return (
<div
key={membership.driverId}
className="flex items-center justify-between gap-3"
>
{driverDto ? (
<DriverIdentity
driver={driverDto}
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
contextLabel="Admin"
meta={meta}
size="sm"
/>
) : (
<span className="text-sm text-gray-400">Unknown admin</span>
)}
</div>
);
})}
</div>
</div>
)}
{adminMemberships.length === 0 && !ownerMembership && (
<p className="text-sm text-gray-500">
Management roles have not been configured for this league yet.
</p>
)}
</div>
</div>
</div>
</Card>
@@ -276,13 +439,6 @@ export default function LeagueDetailPage() {
</Card>
)}
{activeTab === 'members' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">League Members</h2>
<LeagueMembers leagueId={leagueId} key={refreshKey} />
</Card>
)}
{activeTab === 'admin' && isAdmin && (
<LeagueAdmin
league={league}

View File

@@ -8,15 +8,17 @@ import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationToolti
import type { Race } from '@gridpilot/racing/domain/entities/Race';
import type { League } from '@gridpilot/racing/domain/entities/League';
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { getRaceRepository, getLeagueRepository, getDriverRepository } from '@/lib/di-container';
import {
getMembership,
getCurrentDriverId,
isRegistered,
registerForRace,
withdrawFromRace,
getRegisteredDrivers,
} from '@/lib/racingLegacyFacade';
getRaceRepository,
getLeagueRepository,
getDriverRepository,
getGetRaceRegistrationsQuery,
getIsDriverRegisteredForRaceQuery,
getRegisterForRaceUseCase,
getWithdrawFromRaceUseCase,
} from '@/lib/di-container';
import { getMembership } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import CompanionStatus from '@/components/alpha/CompanionStatus';
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
@@ -36,7 +38,7 @@ export default function LeagueRaceDetailPage() {
const [isUserRegistered, setIsUserRegistered] = useState(false);
const [canRegister, setCanRegister] = useState(false);
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
const loadRaceData = async () => {
try {
@@ -67,15 +69,19 @@ export default function LeagueRaceDetailPage() {
const loadEntryList = async (raceIdValue: string, leagueIdValue: string) => {
try {
const driverRepo = getDriverRepository();
const registeredDriverIds = getRegisteredDrivers(raceIdValue);
const drivers = await Promise.all(
registeredDriverIds.map((id: string) => driverRepo.findById(id))
);
setEntryList(
drivers.filter((d: Driver | null): d is Driver => d !== null)
);
const raceRegistrationsQuery = getGetRaceRegistrationsQuery();
const registeredDriverIds = await raceRegistrationsQuery.execute({ raceId: raceIdValue });
const userIsRegistered = isRegistered(raceIdValue, currentDriverId);
const drivers = await Promise.all(
registeredDriverIds.map((id: string) => driverRepo.findById(id)),
);
setEntryList(drivers.filter((d: Driver | null): d is Driver => d !== null));
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
const userIsRegistered = await isRegisteredQuery.execute({
raceId: raceIdValue,
driverId: currentDriverId,
});
setIsUserRegistered(userIsRegistered);
const membership = getMembership(leagueIdValue, currentDriverId);
@@ -124,7 +130,12 @@ export default function LeagueRaceDetailPage() {
setRegistering(true);
try {
registerForRace(race.id, currentDriverId, league.id);
const useCase = getRegisterForRaceUseCase();
await useCase.execute({
raceId: race.id,
leagueId: league.id,
driverId: currentDriverId,
});
await loadEntryList(race.id, league.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register for race');
@@ -144,7 +155,11 @@ export default function LeagueRaceDetailPage() {
setRegistering(true);
try {
withdrawFromRace(race.id, currentDriverId);
const useCase = getWithdrawFromRaceUseCase();
await useCase.execute({
raceId: race.id,
driverId: currentDriverId,
});
await loadEntryList(race.id, league.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');

View File

@@ -3,29 +3,32 @@
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import StandingsTable from '@/components/leagues/StandingsTable';
import type { Standing } from '@gridpilot/racing/domain/entities/Standing';
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { getStandingRepository, getDriverRepository } from '@/lib/di-container';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import { getGetLeagueDriverSeasonStatsQuery, getDriverRepository } from '@/lib/di-container';
export default function LeagueStandingsPage({ params }: { params: { id: string } }) {
const leagueId = params.id;
const [standings, setStandings] = useState<Standing[]>([]);
const [drivers, setDrivers] = useState<Driver[]>([]);
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadData = async () => {
try {
const standingRepo = getStandingRepository();
const getLeagueDriverSeasonStatsQuery = getGetLeagueDriverSeasonStatsQuery();
const driverRepo = getDriverRepository();
const allStandings = await standingRepo.findAll();
const leagueStandings = allStandings.filter((s) => s.leagueId === leagueId);
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
setStandings(leagueStandings);
const allDrivers = await driverRepo.findAll();
setDrivers(allDrivers);
const driverDtos: DriverDTO[] = allDrivers
.map((driver) => EntityMappers.toDriverDTO(driver))
.filter((dto): dto is DriverDTO => dto !== null);
setDrivers(driverDtos);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load standings');
} finally {

View File

@@ -7,12 +7,12 @@ import CreateLeagueForm from '@/components/leagues/CreateLeagueForm';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Input from '@/components/ui/Input';
import { League } from '@gridpilot/racing/domain/entities/League';
import { getLeagueRepository } from '@/lib/di-container';
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
import { getGetAllLeaguesWithCapacityQuery } from '@/lib/di-container';
export default function LeaguesPage() {
const router = useRouter();
const [leagues, setLeagues] = useState<League[]>([]);
const [leagues, setLeagues] = useState<LeagueDTO[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
@@ -24,8 +24,8 @@ export default function LeaguesPage() {
const loadLeagues = async () => {
try {
const leagueRepo = getLeagueRepository();
const allLeagues = await leagueRepo.findAll();
const query = getGetAllLeaguesWithCapacityQuery();
const allLeagues = await query.execute();
setLeagues(allLeagues);
} catch (error) {
console.error('Failed to load leagues:', error);

View File

@@ -0,0 +1,199 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { getLeagueRepository, getLeagueMembershipRepository } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { League } from '@gridpilot/racing/domain/entities/League';
import type { LeagueMembership } from '@gridpilot/racing/domain/entities/LeagueMembership';
interface LeagueWithRole {
league: League;
membership: LeagueMembership;
}
export default function ManageLeaguesPage() {
const [ownedLeagues, setOwnedLeagues] = useState<LeagueWithRole[]>([]);
const [memberLeagues, setMemberLeagues] = useState<LeagueWithRole[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const effectiveDriverId = useEffectiveDriverId();
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
try {
const leagueRepo = getLeagueRepository();
const membershipRepo = getLeagueMembershipRepository();
const leagues = await leagueRepo.findAll();
const memberships = await Promise.all(
leagues.map(async (league) => {
const membership = await membershipRepo.getMembership(league.id, effectiveDriverId);
return { league, membership };
}),
);
if (cancelled) {
return;
}
const owned: LeagueWithRole[] = [];
const member: LeagueWithRole[] = [];
for (const entry of memberships) {
if (!entry.membership || entry.membership.status !== 'active') {
continue;
}
if (entry.membership.role === 'owner') {
owned.push(entry as LeagueWithRole);
} else {
member.push(entry as LeagueWithRole);
}
}
setOwnedLeagues(owned);
setMemberLeagues(member);
setError(null);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load leagues');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [effectiveDriverId]);
if (loading) {
return (
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading your leagues...</div>
</div>
);
}
if (error) {
return (
<div className="max-w-6xl mx-auto">
<Card>
<div className="text-center py-8 text-red-400">{error}</div>
</Card>
</div>
);
}
return (
<div className="max-w-6xl mx-auto space-y-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Manage leagues</h1>
<p className="text-gray-400 text-sm">
View leagues you own and participate in, and jump into league admin tools.
</p>
</div>
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Leagues you own</h2>
{ownedLeagues.length > 0 && (
<span className="text-xs text-gray-400">
{ownedLeagues.length} {ownedLeagues.length === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
{ownedLeagues.length === 0 ? (
<p className="text-sm text-gray-400">
You don't own any leagues yet in this session.
</p>
) : (
<div className="space-y-3">
{ownedLeagues.map(({ league }) => (
<div
key={league.id}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div>
<h3 className="text-white font-medium">{league.name}</h3>
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
{league.description}
</p>
</div>
<div className="flex items-center gap-2">
<Link
href={`/leagues/${league.id}`}
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
>
View
</Link>
<Link href={`/leagues/${league.id}?tab=admin`}>
<Button variant="primary" className="text-xs px-3 py-1.5">
Manage
</Button>
</Link>
</div>
</div>
))}
</div>
)}
</Card>
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Leagues you're in</h2>
{memberLeagues.length > 0 && (
<span className="text-xs text-gray-400">
{memberLeagues.length} {memberLeagues.length === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
{memberLeagues.length === 0 ? (
<p className="text-sm text-gray-400">
You're not a member of any other leagues yet.
</p>
) : (
<div className="space-y-3">
{memberLeagues.map(({ league, membership }) => (
<div
key={league.id}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div>
<h3 className="text-white font-medium">{league.name}</h3>
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
{league.description}
</p>
<p className="text-xs text-gray-500 mt-1">
Your role:{' '}
{membership.role.charAt(0).toUpperCase() + membership.role.slice(1)}
</p>
</div>
<Link
href={`/leagues/${league.id}`}
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
>
View league
</Link>
</div>
))}
</div>
)}
</Card>
</div>
);
}

View File

@@ -1,40 +1,34 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getDriverRepository } from '@/lib/di-container';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import CreateDriverForm from '@/components/drivers/CreateDriverForm';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import ProfileHeader from '@/components/profile/ProfileHeader';
import ProfileStats from '@/components/drivers/ProfileStats';
import DriverProfile from '@/components/drivers/DriverProfile';
import ProfileRaceHistory from '@/components/drivers/ProfileRaceHistory';
import ProfileSettings from '@/components/drivers/ProfileSettings';
import CareerHighlights from '@/components/drivers/CareerHighlights';
import RatingBreakdown from '@/components/drivers/RatingBreakdown';
import { getDriverTeam, getCurrentDriverId } from '@/lib/racingLegacyFacade';
type Tab = 'overview' | 'statistics' | 'history' | 'settings';
import { useEffectiveDriverId } from '@/lib/currentDriver';
export default function ProfilePage() {
const router = useRouter();
const [driver, setDriver] = useState<DriverDTO | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [loading, setLoading] = useState(true);
const effectiveDriverId = useEffectiveDriverId();
useEffect(() => {
const loadDriver = async () => {
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const driverData = EntityMappers.toDriverDTO(drivers[0] || null);
const currentDriverId = effectiveDriverId;
const currentDriver = await driverRepo.findById(currentDriverId);
const driverData = EntityMappers.toDriverDTO(currentDriver);
setDriver(driverData);
setLoading(false);
};
loadDriver();
}, []);
void loadDriver();
}, [effectiveDriverId]);
const handleSaveSettings = async (updates: Partial<DriverDTO>) => {
if (!driver) return;
@@ -86,162 +80,15 @@ export default function ProfilePage() {
);
}
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'statistics', label: 'Statistics' },
{ id: 'history', label: 'Race History' },
{ id: 'settings', label: 'Settings' }
];
return (
<div className="max-w-6xl mx-auto">
<Card className="mb-6">
<ProfileHeader
driver={driver}
isOwnProfile
onEditClick={() => setActiveTab('settings')}
/>
<div className="max-w-6xl mx-auto space-y-6">
<DriverProfile driver={driver} isOwnProfile />
<Card>
<ProfileSettings driver={driver} onSave={handleSaveSettings} />
</Card>
<Card>
<ProfileRaceHistory />
</Card>
<div className="mb-6">
<div className="flex items-center gap-2 border-b border-charcoal-outline">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
px-4 py-3 font-medium transition-all relative
${activeTab === tab.id
? 'text-primary-blue'
: 'text-gray-400 hover:text-white'
}
`}
>
{tab.label}
{activeTab === tab.id && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-blue" />
)}
</button>
))}
</div>
</div>
<div>
{activeTab === 'overview' && (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
{driver.bio ? (
<p className="text-gray-300 leading-relaxed">{driver.bio}</p>
) : (
<p className="text-gray-500 italic">No bio yet. Add one in settings!</p>
)}
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Quick Stats</h3>
<div className="space-y-3">
<StatItem label="Rating" value="1450" color="text-primary-blue" />
<StatItem label="Safety" value="92%" color="text-green-400" />
<StatItem label="Sportsmanship" value="4.8/5" color="text-warning-amber" />
<StatItem label="Total Races" value="147" color="text-white" />
</div>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Preferences</h3>
<div className="space-y-3">
<PreferenceItem icon="🏎️" label="Favorite Car" value="Porsche 911 GT3 R" />
<PreferenceItem icon="🏁" label="Favorite Series" value="Endurance" />
<PreferenceItem icon="⚔️" label="Competitive Level" value="Competitive" />
<PreferenceItem icon="🌍" label="Regions" value="EU, NA" />
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Team</h3>
{(() => {
const currentDriverId = getCurrentDriverId();
const teamData = getDriverTeam(currentDriverId);
if (teamData) {
const { team, membership } = teamData;
return (
<div
className="flex items-center gap-4 p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-charcoal-outline/60 transition-colors cursor-pointer"
onClick={() => router.push(`/teams/${team.id}`)}
>
<div className="w-12 h-12 rounded-lg bg-primary-blue/20 flex items-center justify-center text-xl font-bold text-white">
{team.tag}
</div>
<div className="flex-1">
<div className="text-white font-medium">{team.name}</div>
<div className="text-sm text-gray-400">
{membership.role.charAt(0).toUpperCase() + membership.role.slice(1)} Joined {new Date(membership.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</div>
</div>
</div>
);
}
return (
<div className="text-center py-8">
<p className="text-gray-400 mb-4">You're not on a team yet</p>
<Button
variant="primary"
onClick={() => router.push('/teams')}
>
Browse Teams
</Button>
</div>
);
})()}
</Card>
</div>
<CareerHighlights />
</div>
)}
{activeTab === 'statistics' && (
<div className="space-y-6">
<ProfileStats driverId={driver.id} />
<RatingBreakdown />
</div>
)}
{activeTab === 'history' && (
<ProfileRaceHistory />
)}
{activeTab === 'settings' && (
<ProfileSettings driver={driver} onSave={handleSaveSettings} />
)}
</div>
</div>
);
}
function StatItem({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="flex items-center justify-between">
<span className="text-gray-400 text-sm">{label}</span>
<span className={`font-semibold ${color}`}>{value}</span>
</div>
);
}
function PreferenceItem({ icon, label, value }: { icon: string; label: string; value: string }) {
return (
<div className="flex items-center gap-3">
<span className="text-xl">{icon}</span>
<div className="flex-1">
<div className="text-xs text-gray-500">{label}</div>
<div className="text-white text-sm">{value}</div>
</div>
</div>
);
}

View File

@@ -8,15 +8,17 @@ import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationToolti
import type { Race } from '@gridpilot/racing/domain/entities/Race';
import type { League } from '@gridpilot/racing/domain/entities/League';
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { getRaceRepository, getLeagueRepository, getDriverRepository } from '@/lib/di-container';
import {
getMembership,
getCurrentDriverId,
isRegistered,
registerForRace,
withdrawFromRace,
getRegisteredDrivers,
} from '@/lib/racingLegacyFacade';
getRaceRepository,
getLeagueRepository,
getDriverRepository,
getGetRaceRegistrationsQuery,
getIsDriverRegisteredForRaceQuery,
getRegisterForRaceUseCase,
getWithdrawFromRaceUseCase,
} from '@/lib/di-container';
import { getMembership } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import CompanionStatus from '@/components/alpha/CompanionStatus';
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
@@ -36,7 +38,7 @@ export default function RaceDetailPage() {
const [isUserRegistered, setIsUserRegistered] = useState(false);
const [canRegister, setCanRegister] = useState(false);
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
const loadRaceData = async () => {
try {
@@ -69,19 +71,22 @@ export default function RaceDetailPage() {
const loadEntryList = async (raceId: string, leagueId: string) => {
try {
const driverRepo = getDriverRepository();
const registeredDriverIds = getRegisteredDrivers(raceId);
const drivers = await Promise.all(
registeredDriverIds.map((id: string) => driverRepo.findById(id))
);
setEntryList(
drivers.filter((d: Driver | null): d is Driver => d !== null)
);
// Check user registration status
const userIsRegistered = isRegistered(raceId, currentDriverId);
const raceRegistrationsQuery = getGetRaceRegistrationsQuery();
const registeredDriverIds = await raceRegistrationsQuery.execute({ raceId });
const drivers = await Promise.all(
registeredDriverIds.map((id: string) => driverRepo.findById(id)),
);
setEntryList(drivers.filter((d: Driver | null): d is Driver => d !== null));
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
const userIsRegistered = await isRegisteredQuery.execute({
raceId,
driverId: currentDriverId,
});
setIsUserRegistered(userIsRegistered);
// Check if user can register (is league member and race is upcoming)
const membership = getMembership(leagueId, currentDriverId);
const isUpcoming = race?.status === 'scheduled';
setCanRegister(!!membership && membership.status === 'active' && !!isUpcoming);
@@ -128,7 +133,12 @@ export default function RaceDetailPage() {
setRegistering(true);
try {
registerForRace(race.id, currentDriverId, league.id);
const useCase = getRegisterForRaceUseCase();
await useCase.execute({
raceId: race.id,
leagueId: league.id,
driverId: currentDriverId,
});
await loadEntryList(race.id, league.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register for race');
@@ -148,7 +158,11 @@ export default function RaceDetailPage() {
setRegistering(true);
try {
withdrawFromRace(race.id, currentDriverId);
const useCase = getWithdrawFromRaceUseCase();
await useCase.execute({
raceId: race.id,
driverId: currentDriverId,
});
await loadEntryList(race.id, league.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');

View File

@@ -2,10 +2,8 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import RaceCard from '@/components/races/RaceCard';
import ScheduleRaceForm from '@/components/leagues/ScheduleRaceForm';
import { Race, RaceStatus } from '@gridpilot/racing/domain/entities/Race';
import { League } from '@gridpilot/racing/domain/entities/League';
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
@@ -16,8 +14,6 @@ export default function RacesPage() {
const [races, setRaces] = useState<Race[]>([]);
const [leagues, setLeagues] = useState<Map<string, League>>(new Map());
const [loading, setLoading] = useState(true);
const [showScheduleForm, setShowScheduleForm] = useState(false);
const [preselectedLeagueId, setPreselectedLeagueId] = useState<string | undefined>(undefined);
// Filters
const [statusFilter, setStatusFilter] = useState<RaceStatus | 'all'>('all');
@@ -48,14 +44,6 @@ export default function RacesPage() {
useEffect(() => {
loadRaces();
try {
const params = new URLSearchParams(window.location.search);
const leagueId = params.get('leagueId') || undefined;
setPreselectedLeagueId(leagueId || undefined);
} catch {
setPreselectedLeagueId(undefined);
}
}, []);
const filteredRaces = races.filter(race => {
@@ -90,37 +78,6 @@ export default function RacesPage() {
);
}
if (showScheduleForm) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<button
onClick={() => setShowScheduleForm(false)}
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Races
</button>
</div>
<Card>
<h1 className="text-2xl font-bold text-white mb-6">Schedule New Race</h1>
<ScheduleRaceForm
preSelectedLeagueId={preselectedLeagueId}
onSuccess={(race) => {
router.push(`/races/${race.id}`);
}}
onCancel={() => setShowScheduleForm(false)}
/>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
@@ -128,12 +85,6 @@ export default function RacesPage() {
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl font-bold text-white">Races</h1>
<Button
variant="primary"
onClick={() => setShowScheduleForm(true)}
>
Schedule Race
</Button>
</div>
<p className="text-gray-400">
Manage and view all scheduled races across your leagues

View File

@@ -2,24 +2,22 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Image from 'next/image';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { getImageService } from '@/lib/di-container';
import TeamRoster from '@/components/teams/TeamRoster';
import TeamStandings from '@/components/teams/TeamStandings';
import TeamAdmin from '@/components/teams/TeamAdmin';
import JoinTeamButton from '@/components/teams/JoinTeamButton';
import {
Team,
getTeam,
getTeamMembers,
getCurrentDriverId,
isTeamOwnerOrManager,
TeamMembership,
removeTeamMember,
updateTeamMemberRole,
TeamRole,
} from '@/lib/racingLegacyFacade';
getGetTeamDetailsQuery,
getGetTeamMembersQuery,
getTeamMembershipRepository,
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { Team, TeamMembership, TeamRole } from '@gridpilot/racing';
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
@@ -32,50 +30,87 @@ export default function TeamDetailPage() {
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const currentDriverId = useEffectiveDriverId();
const loadTeamData = useCallback(() => {
const teamData = getTeam(teamId);
if (!teamData) {
const loadTeamData = useCallback(async () => {
setLoading(true);
try {
const detailsQuery = getGetTeamDetailsQuery();
const membersQuery = getGetTeamMembersQuery();
const details = await detailsQuery.execute({ teamId, driverId: currentDriverId });
const teamMemberships = await membersQuery.execute({ teamId });
const adminStatus =
teamMemberships.some(
(m) =>
m.driverId === currentDriverId &&
(m.role === 'owner' || m.role === 'manager'),
) ?? false;
setTeam(details.team);
setMemberships(teamMemberships);
setIsAdmin(adminStatus);
} finally {
setLoading(false);
return;
}
const teamMemberships = getTeamMembers(teamId);
const currentDriverId = getCurrentDriverId();
const adminStatus = isTeamOwnerOrManager(teamId, currentDriverId);
setTeam(teamData);
setMemberships(teamMemberships);
setIsAdmin(adminStatus);
setLoading(false);
}, [teamId]);
}, [teamId, currentDriverId]);
useEffect(() => {
loadTeamData();
void loadTeamData();
}, [loadTeamData]);
const handleUpdate = () => {
loadTeamData();
};
const handleRemoveMember = (driverId: string) => {
const handleRemoveMember = async (driverId: string) => {
if (!confirm('Are you sure you want to remove this member?')) {
return;
}
try {
const currentDriverId = getCurrentDriverId();
removeTeamMember(teamId, driverId, currentDriverId);
const membershipRepo = getTeamMembershipRepository();
const performer = await membershipRepo.getMembership(teamId, currentDriverId);
if (!performer || (performer.role !== 'owner' && performer.role !== 'manager')) {
throw new Error('Only owners or managers can remove members');
}
const membership = await membershipRepo.getMembership(teamId, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot remove the team owner');
}
await membershipRepo.removeMembership(teamId, driverId);
handleUpdate();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to remove member');
}
};
const handleChangeRole = (driverId: string, newRole: TeamRole) => {
const handleChangeRole = async (driverId: string, newRole: TeamRole) => {
try {
const currentDriverId = getCurrentDriverId();
updateTeamMemberRole(teamId, driverId, newRole, currentDriverId);
const membershipRepo = getTeamMembershipRepository();
const performer = await membershipRepo.getMembership(teamId, currentDriverId);
if (!performer || (performer.role !== 'owner' && performer.role !== 'manager')) {
throw new Error('Only owners or managers can update roles');
}
const membership = await membershipRepo.getMembership(teamId, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot change the owner role');
}
await membershipRepo.saveMembership({
...membership,
role: newRole,
});
handleUpdate();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to change role');
@@ -131,10 +166,14 @@ export default function TeamDetailPage() {
<Card className="mb-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-6">
<div className="w-24 h-24 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-4xl font-bold text-gray-500">
{team.tag}
</span>
<div className="w-24 h-24 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
<Image
src={getImageService().getTeamLogo(team.id)}
alt={team.name}
width={96}
height={96}
className="w-full h-full object-cover"
/>
</div>
<div>

View File

@@ -2,12 +2,21 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import TeamCard from '@/components/teams/TeamCard';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Card from '@/components/ui/Card';
import CreateTeamForm from '@/components/teams/CreateTeamForm';
import { getAllTeams, getTeamMembers, type Team } from '@/lib/racingLegacyFacade';
import TeamLadderRow from '@/components/teams/TeamLadderRow';
import { getGetAllTeamsQuery, getGetTeamMembersQuery, getDriverStats } from '@/lib/di-container';
import type { Team } from '@gridpilot/racing';
type TeamLadderItem = {
team: Team;
memberCount: number;
rating: number | null;
totalWins: number;
totalRaces: number;
};
export default function TeamsPage() {
const router = useRouter();
@@ -15,25 +24,72 @@ export default function TeamsPage() {
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [memberFilter, setMemberFilter] = useState('all');
const [memberCounts, setMemberCounts] = useState<Record<string, number>>({});
const [teamStats, setTeamStats] = useState<Record<string, Omit<TeamLadderItem, 'team' | 'memberCount'>>>({});
const loadTeams = async () => {
const allTeamsQuery = getGetAllTeamsQuery();
const teamMembersQuery = getGetTeamMembersQuery();
const allTeams = await allTeamsQuery.execute();
setTeams(allTeams);
const counts: Record<string, number> = {};
const stats: Record<string, Omit<TeamLadderItem, 'team' | 'memberCount'>> = {};
await Promise.all(
allTeams.map(async (team) => {
const memberships = await teamMembersQuery.execute({ teamId: team.id });
counts[team.id] = memberships.length;
const memberDriverIds = memberships.map((m) => m.driverId);
let ratingSum = 0;
let ratingCount = 0;
let totalWins = 0;
let totalRaces = 0;
for (const driverId of memberDriverIds) {
const statsForDriver = getDriverStats(driverId);
if (!statsForDriver) continue;
if (typeof statsForDriver.rating === 'number') {
ratingSum += statsForDriver.rating;
ratingCount += 1;
}
totalWins += statsForDriver.wins ?? 0;
totalRaces += statsForDriver.totalRaces ?? 0;
}
const averageRating =
ratingCount > 0 ? ratingSum / ratingCount : null;
stats[team.id] = {
rating: averageRating,
totalWins,
totalRaces,
};
}),
);
setMemberCounts(counts);
setTeamStats(stats);
};
useEffect(() => {
loadTeams();
void loadTeams();
}, []);
const loadTeams = () => {
const allTeams = getAllTeams();
setTeams(allTeams);
};
const handleCreateSuccess = (teamId: string) => {
setShowCreateForm(false);
loadTeams();
void loadTeams();
router.push(`/teams/${teamId}`);
};
const filteredTeams = teams.filter((team) => {
const memberCount = getTeamMembers(team.id).length;
const memberCount = memberCounts[team.id] ?? 0;
const matchesSearch = team.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesMemberCount =
memberFilter === 'all' ||
@@ -44,6 +100,30 @@ export default function TeamsPage() {
return matchesSearch && matchesMemberCount;
});
const sortedTeams = [...filteredTeams].sort((a, b) => {
const statsA = teamStats[a.id];
const statsB = teamStats[b.id];
const ratingA = statsA?.rating ?? null;
const ratingB = statsB?.rating ?? null;
const ratingValA = ratingA === null ? Number.NEGATIVE_INFINITY : ratingA;
const ratingValB = ratingB === null ? Number.NEGATIVE_INFINITY : ratingB;
if (ratingValA !== ratingValB) {
return ratingValB - ratingValA;
}
const winsA = statsA?.totalWins ?? 0;
const winsB = statsB?.totalWins ?? 0;
if (winsA !== winsB) {
return winsB - winsA;
}
return a.name.localeCompare(b.name);
});
const handleTeamClick = (teamId: string) => {
router.push(`/teams/${teamId}`);
};
@@ -118,27 +198,54 @@ export default function TeamsPage() {
</div>
</Card>
<div className="mb-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-400">
{filteredTeams.length} {filteredTeams.length === 1 ? 'team' : 'teams'} found
{sortedTeams.length} {sortedTeams.length === 1 ? 'team' : 'teams'} found
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTeams.map((team) => {
const memberCount = getTeamMembers(team.id).length;
return (
<TeamCard
key={team.id}
id={team.id}
name={team.name}
memberCount={memberCount}
leagues={team.leagues}
onClick={() => handleTeamClick(team.id)}
/>
);
})}
</div>
{sortedTeams.length > 0 && (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 font-semibold text-gray-400">#</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Team</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Rating</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Wins</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Races</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Members</th>
</tr>
</thead>
<tbody>
{sortedTeams.map((team, index) => {
const memberCount = memberCounts[team.id] ?? 0;
const statsForTeam = teamStats[team.id];
const rating = statsForTeam?.rating ?? null;
const totalWins = statsForTeam?.totalWins ?? 0;
const totalRaces = statsForTeam?.totalRaces ?? 0;
const rank = index + 1;
return (
<TeamLadderRow
key={team.id}
rank={rank}
teamId={team.id}
teamName={team.name}
memberCount={memberCount}
teamRating={rating}
totalWins={totalWins}
totalRaces={totalRaces}
/>
);
})}
</tbody>
</table>
</div>
</Card>
)}
{filteredTeams.length === 0 && (
<Card className="text-center py-12">

View File

@@ -3,20 +3,20 @@
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import UserPill from '@/components/profile/UserPill';
import { useAuth } from '@/lib/auth/AuthContext';
type AlphaNavProps = {
isAuthenticated?: boolean;
};
type AlphaNavProps = Record<string, never>;
const nonHomeLinks = [
{ href: '/profile', label: 'Profile' },
{ href: '/leagues', label: 'Leagues' },
{ href: '/teams', label: 'Teams' },
{ href: '/drivers', label: 'Drivers' },
] as const;
export function AlphaNav({ isAuthenticated }: AlphaNavProps) {
export function AlphaNav({}: AlphaNavProps) {
const pathname = usePathname();
const { session } = useAuth();
const isAuthenticated = !!session;
const navLinks = isAuthenticated
? ([{ href: '/dashboard', label: 'Dashboard' } as const, ...nonHomeLinks] as const)
@@ -64,24 +64,7 @@ export function AlphaNav({ isAuthenticated }: AlphaNavProps) {
</div>
<div className="hidden md:flex items-center space-x-3">
{!isAuthenticated && (
<Link
href={loginHref}
className="inline-flex items-center justify-center px-3 py-1.5 rounded-md bg-primary-blue text-xs font-medium text-white hover:bg-primary-blue/90 transition-colors"
>
Authenticate with iRacing
</Link>
)}
{isAuthenticated && (
<form action="/auth/logout" method="POST">
<button
type="submit"
className="inline-flex items-center justify-center px-3 py-1.5 rounded-md border border-gray-600 text-xs font-medium text-gray-200 hover:bg-gray-800 transition-colors"
>
Logout
</button>
</form>
)}
<UserPill />
</div>
<div className="md:hidden w-8" />

View File

@@ -1,7 +1,7 @@
import Image from 'next/image';
import Card from '@/components/ui/Card';
import RankBadge from '@/components/drivers/RankBadge';
import { getDriverAvatarUrl } from '@/lib/racingLegacyFacade';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
export interface DriverCardProps {
id: string;
@@ -29,6 +29,14 @@ export default function DriverCard(props: DriverCardProps) {
onClick,
} = props;
const driver: DriverDTO = {
id,
iracingId: '',
name,
country: nationality,
joinedAt: '',
};
return (
<Card
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
@@ -38,22 +46,12 @@ export default function DriverCard(props: DriverCardProps) {
<div className="flex items-center gap-4 flex-1">
<RankBadge rank={rank} size="lg" />
<div className="w-16 h-16 rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center">
<Image
src={getDriverAvatarUrl(id)}
alt={name}
width={64}
height={64}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-white mb-1">{name}</h3>
<p className="text-sm text-gray-400">
{nationality} {racesCompleted} races
</p>
</div>
<DriverIdentity
driver={driver}
href={`/drivers/${id}`}
meta={`${nationality}${racesCompleted} races`}
size="md"
/>
</div>
<div className="flex items-center gap-8 text-center">

View File

@@ -0,0 +1,63 @@
import Link from 'next/link';
import Image from 'next/image';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { getImageService } from '@/lib/di-container';
export interface DriverIdentityProps {
driver: DriverDTO;
href?: string;
contextLabel?: React.ReactNode;
meta?: React.ReactNode;
size?: 'sm' | 'md';
}
export default function DriverIdentity(props: DriverIdentityProps) {
const { driver, href, contextLabel, meta, size = 'md' } = props;
const avatarSize = size === 'sm' ? 40 : 48;
const nameTextClasses =
size === 'sm'
? 'text-sm font-medium text-white'
: 'text-base md:text-lg font-semibold text-white';
const metaTextClasses = 'text-xs md:text-sm text-gray-400';
const content = (
<div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
<div
className={`rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0`}
style={{ width: avatarSize, height: avatarSize }}
>
<Image
src={getImageService().getDriverAvatar(driver.id)}
alt={driver.name}
width={avatarSize}
height={avatarSize}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className={`${nameTextClasses} truncate`}>{driver.name}</span>
{contextLabel ? (
<span className="inline-flex items-center rounded-full bg-charcoal-outline/60 px-2 py-0.5 text-[10px] md:text-xs font-medium text-gray-200">
{contextLabel}
</span>
) : null}
</div>
{meta ? <div className={`${metaTextClasses} mt-0.5 truncate`}>{meta}</div> : null}
</div>
</div>
);
if (href) {
return (
<Link href={href} className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
{content}
</Link>
);
}
return <div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">{content}</div>;
}

View File

@@ -7,16 +7,30 @@ import ProfileStats from './ProfileStats';
import CareerHighlights from './CareerHighlights';
import DriverRankings from './DriverRankings';
import PerformanceMetrics from './PerformanceMetrics';
import { getDriverTeam } from '@/lib/racingLegacyFacade';
import { getDriverStats, getLeagueRankings } from '@/lib/di-container';
import { useEffect, useState } from 'react';
import { getDriverStats, getLeagueRankings, getGetDriverTeamQuery, getAllDriverRankings } from '@/lib/di-container';
import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO';
interface DriverProfileProps {
driver: DriverDTO;
isOwnProfile?: boolean;
onEditClick?: () => void;
}
export default function DriverProfile({ driver }: DriverProfileProps) {
export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
const driverStats = getDriverStats(driver.id);
const leagueRank = getLeagueRankings(driver.id, 'league-1');
const allRankings = getAllDriverRankings();
const [teamData, setTeamData] = useState<GetDriverTeamQueryResultDTO | null>(null);
useEffect(() => {
const load = async () => {
const query = getGetDriverTeamQuery();
const result = await query.execute({ driverId: driver.id });
setTeamData(result);
};
void load();
}, [driver.id]);
const performanceStats = driverStats ? {
winRate: (driverStats.wins / driverStats.totalRaces) * 100,
@@ -33,7 +47,7 @@ export default function DriverProfile({ driver }: DriverProfileProps) {
type: 'overall' as const,
name: 'Overall Ranking',
rank: driverStats.overallRank,
totalDrivers: 850,
totalDrivers: allRankings.length,
percentile: driverStats.percentile,
rating: driverStats.rating,
},
@@ -50,7 +64,15 @@ export default function DriverProfile({ driver }: DriverProfileProps) {
return (
<div className="space-y-6">
<Card>
<ProfileHeader driver={driver} isOwnProfile={false} />
<ProfileHeader
driver={driver}
rating={driverStats?.rating ?? null}
rank={driverStats?.overallRank ?? null}
isOwnProfile={isOwnProfile}
onEditClick={isOwnProfile ? onEditClick : undefined}
teamName={teamData?.team.name ?? null}
teamTag={teamData?.team.tag ?? null}
/>
</Card>
{driver.bio && (
@@ -82,46 +104,13 @@ export default function DriverProfile({ driver }: DriverProfileProps) {
{!driverStats && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<Card className="lg:col-span-3">
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3>
<div className="grid grid-cols-2 gap-4">
<StatCard label="Rating" value="1450" color="text-primary-blue" />
<StatCard label="Total Races" value="147" color="text-white" />
<StatCard label="Wins" value="23" color="text-green-400" />
<StatCard label="Podiums" value="56" color="text-warning-amber" />
</div>
<p className="text-gray-400 text-sm">
No statistics available yet. Compete in races to start building your record.
</p>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Team</h3>
{(() => {
const teamData = getDriverTeam(driver.id);
if (teamData) {
const { team, membership } = teamData;
return (
<div className="flex items-center gap-4 p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
<div className="w-12 h-12 rounded-lg bg-primary-blue/20 flex items-center justify-center text-xl font-bold text-white">
{team.tag}
</div>
<div>
<div className="text-white font-medium">{team.name}</div>
<div className="text-sm text-gray-400">
{membership.role.charAt(0).toUpperCase() + membership.role.slice(1)} Joined {new Date(membership.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</div>
</div>
</div>
);
}
return (
<div className="text-center py-4 text-gray-400 text-sm">
Not on a team
</div>
);
})()}
</Card>
</div>
</div>
)}
<Card>

View File

@@ -20,25 +20,28 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
const driverStats = driverId ? getDriverStats(driverId) : null;
const allRankings = getAllDriverRankings();
const leagueRank = driverId ? getLeagueRankings(driverId, 'league-1') : null;
const defaultStats = stats || (driverStats ? {
totalRaces: driverStats.totalRaces,
wins: driverStats.wins,
podiums: driverStats.podiums,
dnfs: driverStats.dnfs,
avgFinish: driverStats.avgFinish,
completionRate: ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
} : {
totalRaces: 147,
wins: 23,
podiums: 56,
dnfs: 12,
avgFinish: 5.8,
completionRate: 91.8
});
const winRate = ((defaultStats.wins / defaultStats.totalRaces) * 100).toFixed(1);
const podiumRate = ((defaultStats.podiums / defaultStats.totalRaces) * 100).toFixed(1);
const defaultStats = stats || (driverStats
? {
totalRaces: driverStats.totalRaces,
wins: driverStats.wins,
podiums: driverStats.podiums,
dnfs: driverStats.dnfs,
avgFinish: driverStats.avgFinish,
completionRate:
((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) *
100,
}
: null);
const winRate =
defaultStats && defaultStats.totalRaces > 0
? ((defaultStats.wins / defaultStats.totalRaces) * 100).toFixed(1)
: '0.0';
const podiumRate =
defaultStats && defaultStats.totalRaces > 0
? ((defaultStats.podiums / defaultStats.totalRaces) * 100).toFixed(1)
: '0.0';
const getTrendIndicator = (value: number) => {
if (value > 0) return '↑';
@@ -131,41 +134,74 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
</Card>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: 'Total Races', value: defaultStats.totalRaces, color: 'text-primary-blue' },
{ label: 'Wins', value: defaultStats.wins, color: 'text-green-400' },
{ label: 'Podiums', value: defaultStats.podiums, color: 'text-warning-amber' },
{ label: 'DNFs', value: defaultStats.dnfs, color: 'text-red-400' },
{ label: 'Avg Finish', value: defaultStats.avgFinish.toFixed(1), color: 'text-white' },
{ label: 'Completion', value: `${defaultStats.completionRate.toFixed(1)}%`, color: 'text-green-400' },
{ label: 'Win Rate', value: `${winRate}%`, color: 'text-primary-blue' },
{ label: 'Podium Rate', value: `${podiumRate}%`, color: 'text-warning-amber' }
].map((stat, index) => (
<Card key={index} className="text-center">
<div className="text-sm text-gray-400 mb-1">{stat.label}</div>
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
</Card>
))}
</div>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Performance by Car Class</h3>
<div className="space-y-3 text-sm">
<PerformanceRow label="GT3" races={45} wins={12} podiums={23} avgFinish={4.2} />
<PerformanceRow label="Formula" races={38} wins={7} podiums={15} avgFinish={6.1} />
<PerformanceRow label="LMP2" races={32} wins={4} podiums={11} avgFinish={7.3} />
<PerformanceRow label="Other" races={32} wins={0} podiums={7} avgFinish={8.5} />
</div>
</Card>
{defaultStats ? (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{
label: 'Total Races',
value: defaultStats.totalRaces,
color: 'text-primary-blue',
},
{ label: 'Wins', value: defaultStats.wins, color: 'text-green-400' },
{
label: 'Podiums',
value: defaultStats.podiums,
color: 'text-warning-amber',
},
{ label: 'DNFs', value: defaultStats.dnfs, color: 'text-red-400' },
{
label: 'Avg Finish',
value: defaultStats.avgFinish.toFixed(1),
color: 'text-white',
},
{
label: 'Completion',
value: `${defaultStats.completionRate.toFixed(1)}%`,
color: 'text-green-400',
},
{ label: 'Win Rate', value: `${winRate}%`, color: 'text-primary-blue' },
{
label: 'Podium Rate',
value: `${podiumRate}%`,
color: 'text-warning-amber',
},
].map((stat, index) => (
<Card key={index} className="text-center">
<div className="text-sm text-gray-400 mb-1">{stat.label}</div>
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
</Card>
))}
</div>
</>
) : (
<Card>
<h3 className="text-lg font-semibold text-white mb-2">Career Statistics</h3>
<p className="text-sm text-gray-400">
No statistics available yet. Compete in races to start building your record.
</p>
</Card>
)}
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">📊</div>
<h3 className="text-lg font-semibold text-white">Performance by Car Class</h3>
</div>
<p className="text-gray-400 text-sm">
Detailed per-car and per-class performance breakdowns will be available in a future
version once more race history data is tracked.
</p>
</Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">📈</div>
<h3 className="text-lg font-semibold text-white">Coming Soon</h3>
</div>
<p className="text-gray-400 text-sm">
Performance trends, track-specific stats, head-to-head comparisons vs friends, and league member comparisons will be available in production.
Performance trends, track-specific stats, head-to-head comparisons vs friends, and
league member comparisons will be available in production.
</p>
</Card>
</div>

View File

@@ -2,14 +2,9 @@
import { useState } from 'react';
import Button from '../ui/Button';
import {
getMembership,
joinLeague,
leaveLeague,
requestToJoin,
getCurrentDriverId,
type MembershipStatus,
} from '@/lib/racingLegacyFacade';
import { getMembership, type MembershipStatus } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { getJoinLeagueUseCase, getLeagueMembershipRepository } from '@/lib/di-container';
interface JoinLeagueButtonProps {
leagueId: string;
@@ -22,9 +17,9 @@ export default function JoinLeagueButton({
isInviteOnly = false,
onMembershipChange,
}: JoinLeagueButtonProps) {
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
const membership = getMembership(leagueId, currentDriverId);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
@@ -34,11 +29,27 @@ export default function JoinLeagueButton({
setLoading(true);
setError(null);
try {
const membershipRepo = getLeagueMembershipRepository();
if (isInviteOnly) {
requestToJoin(leagueId, currentDriverId);
// For alpha, treat "request to join" as creating a pending membership
const pending = await membershipRepo.getMembership(leagueId, currentDriverId);
if (pending) {
throw new Error('Already a member or have a pending request');
}
await membershipRepo.saveMembership({
leagueId,
driverId: currentDriverId,
role: 'member',
status: 'pending',
joinedAt: new Date(),
});
} else {
joinLeague(leagueId, currentDriverId);
const useCase = getJoinLeagueUseCase();
await useCase.execute({ leagueId, driverId: currentDriverId });
}
onMembershipChange?.();
setShowConfirmDialog(false);
} catch (err) {
@@ -52,7 +63,16 @@ export default function JoinLeagueButton({
setLoading(true);
setError(null);
try {
leaveLeague(leagueId, currentDriverId);
const membershipRepo = getLeagueMembershipRepository();
const existing = await membershipRepo.getMembership(leagueId, currentDriverId);
if (!existing) {
throw new Error('Not a member of this league');
}
if (existing.role === 'owner') {
throw new Error('League owner cannot leave the league');
}
await membershipRepo.removeMembership(leagueId, currentDriverId);
onMembershipChange?.();
setShowConfirmDialog(false);
} catch (err) {

View File

@@ -1,23 +1,33 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import Button from '../ui/Button';
import Card from '../ui/Card';
import LeagueMembers from './LeagueMembers';
import ScheduleRaceForm from './ScheduleRaceForm';
import { League } from '@gridpilot/racing/domain/entities/League';
import {
getJoinRequests,
approveJoinRequest,
rejectJoinRequest,
removeMember,
updateMemberRole,
getCurrentDriverId,
type JoinRequest,
type MembershipRole,
} from '@/lib/racingLegacyFacade';
import { getDriverRepository } from '@/lib/di-container';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
getLeagueMembershipRepository,
getDriverStats,
getAllDriverRankings,
getDriverRepository,
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { MembershipRole } from '@/lib/leagueMembership';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import Modal from '@/components/ui/Modal';
interface JoinRequest {
id: string;
leagueId: string;
driverId: string;
requestedAt: Date;
message?: string;
}
interface LeagueAdminProps {
league: League;
@@ -26,24 +36,38 @@ interface LeagueAdminProps {
export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps) {
const router = useRouter();
const currentDriverId = getCurrentDriverId();
const searchParams = useSearchParams();
const pathname = usePathname();
const currentDriverId = useEffectiveDriverId();
const [joinRequests, setJoinRequests] = useState<JoinRequest[]>([]);
const [requestDrivers, setRequestDrivers] = useState<Driver[]>([]);
const [requestDriversById, setRequestDriversById] = useState<Record<string, DriverDTO>>({});
const [ownerDriver, setOwnerDriver] = useState<DriverDTO | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings'>('members');
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'disputes'>('members');
const [rejectReason, setRejectReason] = useState('');
const loadJoinRequests = useCallback(async () => {
setLoading(true);
try {
const requests = getJoinRequests(league.id);
const membershipRepo = getLeagueMembershipRepository();
const requests = await membershipRepo.getJoinRequests(league.id);
setJoinRequests(requests);
const driverRepo = getDriverRepository();
const drivers = await Promise.all(
requests.map(r => driverRepo.findById(r.driverId))
const uniqueDriverIds = Array.from(new Set(requests.map((r) => r.driverId)));
const driverEntities = await Promise.all(
uniqueDriverIds.map((id) => driverRepo.findById(id)),
);
setRequestDrivers(drivers.filter((d): d is Driver => d !== null));
const driverDtos = driverEntities
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
.filter((dto): dto is DriverDTO => dto !== null);
const byId: Record<string, DriverDTO> = {};
for (const dto of driverDtos) {
byId[dto.id] = dto;
}
setRequestDriversById(byId);
} catch (err) {
console.error('Failed to load join requests:', err);
} finally {
@@ -55,52 +79,189 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
loadJoinRequests();
}, [loadJoinRequests]);
const handleApproveRequest = (requestId: string) => {
useEffect(() => {
async function loadOwner() {
try {
const driverRepo = getDriverRepository();
const entity = await driverRepo.findById(league.ownerId);
setOwnerDriver(EntityMappers.toDriverDTO(entity));
} catch (err) {
console.error('Failed to load league owner:', err);
}
}
loadOwner();
}, [league.ownerId]);
const handleApproveRequest = async (requestId: string) => {
try {
approveJoinRequest(requestId);
loadJoinRequests();
const membershipRepo = getLeagueMembershipRepository();
const requests = await membershipRepo.getJoinRequests(league.id);
const request = requests.find((r) => r.id === requestId);
if (!request) {
throw new Error('Join request not found');
}
await membershipRepo.saveMembership({
leagueId: request.leagueId,
driverId: request.driverId,
role: 'member',
status: 'active',
joinedAt: new Date(),
});
await membershipRepo.removeJoinRequest(requestId);
await loadJoinRequests();
onLeagueUpdate?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to approve request');
}
};
const handleRejectRequest = (requestId: string) => {
const handleRejectRequest = async (requestId: string, trimmedReason: string) => {
try {
rejectJoinRequest(requestId);
loadJoinRequests();
const membershipRepo = getLeagueMembershipRepository();
// Alpha-only: we do not persist the reason yet, but we at least log it.
console.log('Join request rejected with reason:', {
requestId,
reason: trimmedReason,
});
await membershipRepo.removeJoinRequest(requestId);
await loadJoinRequests();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reject request');
}
};
const handleRemoveMember = (driverId: string) => {
const handleRemoveMember = async (driverId: string) => {
if (!confirm('Are you sure you want to remove this member?')) {
return;
}
try {
removeMember(league.id, driverId, currentDriverId);
const membershipRepo = getLeagueMembershipRepository();
const performer = await membershipRepo.getMembership(league.id, currentDriverId);
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
throw new Error('Only owners or admins can remove members');
}
const membership = await membershipRepo.getMembership(league.id, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot remove the league owner');
}
await membershipRepo.removeMembership(league.id, driverId);
onLeagueUpdate?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove member');
}
};
const handleUpdateRole = (driverId: string, newRole: MembershipRole) => {
const handleUpdateRole = async (driverId: string, newRole: MembershipRole) => {
try {
updateMemberRole(league.id, driverId, newRole, currentDriverId);
const membershipRepo = getLeagueMembershipRepository();
const performer = await membershipRepo.getMembership(league.id, currentDriverId);
if (!performer || performer.role !== 'owner') {
throw new Error('Only the league owner can update roles');
}
const membership = await membershipRepo.getMembership(league.id, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot change the owner role');
}
await membershipRepo.saveMembership({
...membership,
role: newRole,
});
onLeagueUpdate?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
}
};
const getDriverName = (driverId: string): string => {
const driver = requestDrivers.find(d => d.id === driverId);
return driver?.name || 'Unknown Driver';
const modal = searchParams?.get('modal');
const modalRequestId = searchParams?.get('requestId');
const activeRejectRequest =
modal === 'reject-request'
? joinRequests.find((r) => r.id === modalRequestId) ?? null
: null;
useEffect(() => {
if (!activeRejectRequest) {
setRejectReason('');
}
}, [activeRejectRequest?.id]);
const isRejectModalOpen = modal === 'reject-request' && !!activeRejectRequest;
const openRejectModal = (requestId: string) => {
const params = new URLSearchParams(searchParams?.toString());
params.set('modal', 'reject-request');
params.set('requestId', requestId);
const query = params.toString();
const url = query ? `${pathname}?${query}` : pathname;
router.push(url, { scroll: false });
};
const closeModal = () => {
const params = new URLSearchParams(searchParams?.toString());
params.delete('modal');
params.delete('requestId');
const query = params.toString();
const url = query ? `${pathname}?${query}` : pathname;
router.push(url, { scroll: false });
};
const ownerSummary = useMemo(() => {
if (!ownerDriver) {
return null;
}
const stats = getDriverStats(ownerDriver.id);
const allRankings = getAllDriverRankings();
let rating: number | null = stats?.rating ?? null;
let rank: number | null = null;
if (stats) {
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
rank = stats.overallRank;
} else {
const indexInGlobal = allRankings.findIndex(
(stat) => stat.driverId === stats.driverId,
);
if (indexInGlobal !== -1) {
rank = indexInGlobal + 1;
}
}
if (rating === null) {
const globalEntry = allRankings.find(
(stat) => stat.driverId === stats.driverId,
);
if (globalEntry) {
rating = globalEntry.rating;
}
}
}
return {
driver: ownerDriver,
rating,
rank,
};
}, [ownerDriver]);
return (
<div>
{error && (
@@ -153,6 +314,16 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
>
Create Race
</button>
<button
onClick={() => setActiveTab('disputes')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'disputes'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Disputes
</button>
<button
onClick={() => setActiveTab('settings')}
className={`pb-3 px-1 font-medium transition-colors ${
@@ -191,48 +362,60 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
</div>
) : (
<div className="space-y-4">
{joinRequests.map((request) => (
<div
key={request.id}
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-white font-medium">
{getDriverName(request.driverId)}
</h3>
<p className="text-sm text-gray-400 mt-1">
Requested {new Date(request.requestedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
{request.message && (
<p className="text-sm text-gray-400 mt-2 italic">
&ldquo;{request.message}&rdquo;
</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="primary"
onClick={() => handleApproveRequest(request.id)}
className="px-4"
>
Approve
</Button>
<Button
variant="secondary"
onClick={() => handleRejectRequest(request.id)}
className="px-4"
>
Reject
</Button>
{joinRequests.map((request) => {
const driver = requestDriversById[request.driverId];
const requestedOn = new Date(request.requestedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const metaPieces = [
`Requested ${requestedOn}`,
request.message ? `Message: "${request.message}"` : null,
].filter(Boolean);
const meta = metaPieces.join(' • ');
return (
<div
key={request.id}
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
{driver ? (
<DriverIdentity
driver={driver}
href={`/drivers/${request.driverId}?from=league-join-requests&leagueId=${league.id}`}
meta={meta}
size="sm"
/>
) : (
<div>
<h3 className="text-white font-medium">Unknown Driver</h3>
<p className="text-sm text-gray-400 mt-1">Unable to load driver details</p>
</div>
)}
</div>
<div className="flex gap-2 shrink-0">
<Button
variant="primary"
onClick={() => handleApproveRequest(request.id)}
className="px-4"
>
Approve
</Button>
<Button
variant="secondary"
onClick={() => openRejectModal(request.id)}
className="px-4"
>
Reject
</Button>
</div>
</div>
</div>
</div>
))}
);
})}
</div>
)}
</Card>
@@ -240,16 +423,40 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
{activeTab === 'races' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Create New Race</h2>
<p className="text-gray-400 mb-4">
Schedule a new race for this league
<h2 className="text-xl font-semibold text-white mb-4">Schedule Race</h2>
<p className="text-gray-400 mb-6">
Create a new race for this league; this is an alpha-only in-memory scheduler.
</p>
<Button
variant="primary"
onClick={() => router.push(`/races?leagueId=${league.id}`)}
>
Go to Race Scheduler
</Button>
<ScheduleRaceForm
preSelectedLeagueId={league.id}
onSuccess={(race) => {
router.push(`/leagues/${league.id}/races/${race.id}`);
}}
/>
</Card>
)}
{activeTab === 'disputes' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Disputes (Alpha)</h2>
<div className="space-y-4">
<p className="text-sm text-gray-400">
Demo-only view of potential protest and dispute workflow for this league.
</p>
<div className="rounded-lg border border-charcoal-outline bg-deep-graphite/70 p-4">
<h3 className="text-sm font-semibold text-white mb-1">Sample Protest</h3>
<p className="text-xs text-gray-400 mb-2">
Driver contact in Turn 3, Lap 12. Protest submitted by a driver against another
competitor for avoidable contact.
</p>
<p className="text-xs text-gray-500">
In the full product, this area would show protests, steward reviews, penalties, and appeals.
</p>
</div>
<p className="text-xs text-gray-500">
For the alpha, this tab is static and read-only and does not affect any race or league state.
</p>
</div>
</Card>
)}
@@ -257,50 +464,165 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
<Card>
<h2 className="text-xl font-semibold text-white mb-4">League Settings</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
League Name
</label>
<p className="text-white">{league.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<p className="text-white">{league.description}</p>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">Racing Settings</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div>
<label className="text-sm text-gray-500">Points System</label>
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
<label className="block text-sm font-medium text-gray-300 mb-2">
League Name
</label>
<p className="text-white">{league.name}</p>
</div>
<div>
<label className="text-sm text-gray-500">Session Duration</label>
<p className="text-white">{league.settings.sessionDuration} minutes</p>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<p className="text-white">{league.description}</p>
</div>
<div>
<label className="text-sm text-gray-500">Qualifying Format</label>
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2 border-t border-charcoal-outline">
<div>
<label className="text-sm text-gray-500">Season / Series</label>
<p className="text-white">Alpha Demo Season</p>
</div>
<div>
<label className="text-sm text-gray-500">Points System</label>
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
</div>
<div>
<label className="text-sm text-gray-500">Qualifying Format</label>
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
</div>
</div>
{league.socialLinks && (
<div className="pt-4 border-t border-charcoal-outline space-y-2">
<h3 className="text-sm font-medium text-gray-300">Social Links</h3>
<div className="space-y-1 text-sm">
{league.socialLinks.discordUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">Discord</span>
<a
href={league.socialLinks.discordUrl}
target="_blank"
rel="noreferrer"
className="text-primary-blue hover:underline break-all"
>
{league.socialLinks.discordUrl}
</a>
</div>
)}
{league.socialLinks.youtubeUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">YouTube</span>
<a
href={league.socialLinks.youtubeUrl}
target="_blank"
rel="noreferrer"
className="text-red-400 hover:underline break-all"
>
{league.socialLinks.youtubeUrl}
</a>
</div>
)}
{league.socialLinks.websiteUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">Website</span>
<a
href={league.socialLinks.websiteUrl}
target="_blank"
rel="noreferrer"
className="text-gray-100 hover:underline break-all"
>
{league.socialLinks.websiteUrl}
</a>
</div>
)}
{!league.socialLinks.discordUrl &&
!league.socialLinks.youtubeUrl &&
!league.socialLinks.websiteUrl && (
<p className="text-gray-500">
No social links configured for this league in the alpha demo.
</p>
)}
</div>
</div>
)}
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-300">League Owner</h3>
{ownerSummary ? (
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
/>
) : (
<p className="text-sm text-gray-500">Loading owner details...</p>
)}
</div>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<p className="text-sm text-gray-400">
League settings editing will be available in a future update
League settings editing is alpha-only and changes are not persisted yet.
</p>
</div>
</div>
</Card>
)}
<Modal
title="Reject join request"
description={
activeRejectRequest
? `Provide a reason for rejecting ${requestDriversById[activeRejectRequest.driverId]?.name ?? 'this driver'}.`
: 'Provide a reason for rejecting this join request.'
}
primaryActionLabel="Reject"
secondaryActionLabel="Cancel"
onPrimaryAction={async () => {
const trimmed = rejectReason.trim();
if (!trimmed) {
setError('A rejection reason is required to reject a join request.');
return;
}
if (!activeRejectRequest) {
return;
}
await handleRejectRequest(activeRejectRequest.id, trimmed);
closeModal();
}}
onSecondaryAction={() => {
setRejectReason('');
}}
onOpenChange={(open) => {
if (!open) {
closeModal();
}
}}
isOpen={isRejectModalOpen}
>
<div className="space-y-3">
<p className="text-sm text-gray-300">
This will remove the join request and the driver will not be added to the league.
</p>
<div>
<label className="block text-sm font-medium text-gray-200 mb-1">
Rejection reason
</label>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={4}
className="w-full rounded-lg border border-charcoal-outline bg-iron-gray/80 px-3 py-2 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-blue"
placeholder="Let the driver know why this request was rejected..."
/>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,16 +1,22 @@
'use client';
import Link from 'next/link';
import { League } from '@gridpilot/racing/domain/entities/League';
import Image from 'next/image';
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
import Card from '../ui/Card';
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import { getImageService } from '@/lib/di-container';
interface LeagueCardProps {
league: League;
league: LeagueDTO;
onClick?: () => void;
}
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
const imageService = getImageService();
const coverUrl = imageService.getLeagueCover(league.id);
const logoUrl = imageService.getLeagueLogo(league.id);
return (
<div
className="cursor-pointer hover:scale-[1.03] transition-transform duration-150"
@@ -18,7 +24,28 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
>
<Card>
<div className="space-y-3">
<div className={getLeagueCoverClasses(league.id)} aria-hidden="true" />
<div className={getLeagueCoverClasses(league.id)} aria-hidden="true">
<div className="relative w-full h-full">
<Image
src={coverUrl}
alt={`${league.name} cover`}
fill
className="object-cover opacity-80"
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
/>
<div className="absolute left-4 bottom-4 flex items-center">
<div className="w-10 h-10 rounded-full overflow-hidden border border-charcoal-outline/80 bg-deep-graphite/80 shadow-[0_0_10px_rgba(0,0,0,0.6)]">
<Image
src={logoUrl}
alt={`${league.name} logo`}
width={40}
height={40}
className="w-full h-full object-cover"
/>
</div>
</div>
</div>
</div>
<div className="flex items-start justify-between">
<h3 className="text-xl font-semibold text-white">{league.name}</h3>
@@ -32,14 +59,27 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
</p>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<div className="text-xs text-gray-500">
Owner:{' '}
<Link
href={`/drivers/${league.ownerId}?from=league&leagueId=${league.id}`}
className="text-primary-blue hover:underline"
>
{league.ownerId.slice(0, 8)}...
</Link>
<div className="flex flex-col text-xs text-gray-500">
<span>
Owner:{' '}
<Link
href={`/drivers/${league.ownerId}?from=league&leagueId=${league.id}`}
className="text-primary-blue hover:underline"
>
{league.ownerId.slice(0, 8)}...
</Link>
</span>
<span className="mt-1 text-gray-400">
Slots:{' '}
<span className="text-white font-medium">
{typeof league.usedSlots === 'number' ? league.usedSlots : '—'}
</span>
{' / '}
<span className="text-gray-300">
{league.settings.maxDrivers ?? '—'}
</span>{' '}
used
</span>
</div>
<div className="text-xs text-primary-blue font-medium">
{league.settings.pointsSystem.toUpperCase()}

View File

@@ -1,11 +1,20 @@
'use client';
import React from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import MembershipStatus from '@/components/leagues/MembershipStatus';
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import {
getDriverRepository,
getDriverStats,
getAllDriverRankings,
getImageService,
} from '@/lib/di-container';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
interface LeagueHeaderProps {
leagueId: string;
@@ -22,7 +31,72 @@ export default function LeagueHeader({
ownerId,
ownerName,
}: LeagueHeaderProps) {
const coverUrl = `https://picsum.photos/seed/${leagueId}/1200/280?blur=2`;
const imageService = getImageService();
const coverUrl = imageService.getLeagueCover(leagueId);
const logoUrl = imageService.getLeagueLogo(leagueId);
const [ownerDriver, setOwnerDriver] = useState<DriverDTO | null>(null);
useEffect(() => {
let isMounted = true;
async function loadOwner() {
try {
const driverRepo = getDriverRepository();
const entity = await driverRepo.findById(ownerId);
if (!entity || !isMounted) return;
setOwnerDriver(EntityMappers.toDriverDTO(entity));
} catch (err) {
console.error('Failed to load league owner for header:', err);
}
}
loadOwner();
return () => {
isMounted = false;
};
}, [ownerId]);
const ownerSummary = useMemo(() => {
if (!ownerDriver) {
return null;
}
const stats = getDriverStats(ownerDriver.id);
const allRankings = getAllDriverRankings();
let rating: number | null = stats?.rating ?? null;
let rank: number | null = null;
if (stats) {
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
rank = stats.overallRank;
} else {
const indexInGlobal = allRankings.findIndex(
(stat) => stat.driverId === stats.driverId,
);
if (indexInGlobal !== -1) {
rank = indexInGlobal + 1;
}
}
if (rating === null) {
const globalEntry = allRankings.find(
(stat) => stat.driverId === stats.driverId,
);
if (globalEntry) {
rating = globalEntry.rating;
}
}
}
return {
driver: ownerDriver,
rating,
rank,
};
}, [ownerDriver]);
return (
<div className="mb-8">
@@ -36,6 +110,17 @@ export default function LeagueHeader({
className="object-cover opacity-80"
sizes="100vw"
/>
<div className="absolute left-6 bottom-4 flex items-center">
<div className="h-16 w-16 rounded-full overflow-hidden border-2 border-charcoal-outline bg-deep-graphite/95 shadow-[0_0_18px_rgba(0,0,0,0.7)]">
<Image
src={logoUrl}
alt={`${leagueName} logo`}
width={64}
height={64}
className="w-full h-full object-cover"
/>
</div>
</div>
</div>
</div>
</div>
@@ -56,14 +141,27 @@ export default function LeagueHeader({
<p className="text-gray-400 mb-2">{description}</p>
)}
<div className="text-sm text-gray-400 mb-6">
<span className="mr-2">Owner:</span>
<Link
href={`/drivers/${ownerId}?from=league&leagueId=${leagueId}`}
className="text-primary-blue hover:underline"
>
{ownerName}
</Link>
<div className="mb-6 flex flex-col gap-2">
<span className="text-sm text-gray-400">Owner</span>
{ownerSummary ? (
<div className="inline-flex items-center gap-3">
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
href={`/drivers/${ownerSummary.driver.id}?from=league&leagueId=${leagueId}`}
/>
</div>
) : (
<div className="text-sm text-gray-500">
<Link
href={`/drivers/${ownerId}?from=league&leagueId=${leagueId}`}
className="text-primary-blue hover:underline"
>
{ownerName}
</Link>
</div>
)}
</div>
</div>
);

View File

@@ -1,15 +1,16 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
import {
getLeagueMembers,
getCurrentDriverId,
type LeagueMembership,
type MembershipRole,
} from '@/lib/racingLegacyFacade';
} from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
interface LeagueMembersProps {
leagueId: string;
@@ -18,17 +19,17 @@ interface LeagueMembersProps {
showActions?: boolean;
}
export default function LeagueMembers({
leagueId,
onRemoveMember,
export default function LeagueMembers({
leagueId,
onRemoveMember,
onUpdateRole,
showActions = false
showActions = false
}: LeagueMembersProps) {
const [members, setMembers] = useState<LeagueMembership[]>([]);
const [drivers, setDrivers] = useState<Driver[]>([]);
const [driversById, setDriversById] = useState<Record<string, DriverDTO>>({});
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<'role' | 'name' | 'date' | 'rating' | 'points' | 'wins'>('rating');
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
const loadMembers = useCallback(async () => {
setLoading(true);
@@ -37,10 +38,18 @@ export default function LeagueMembers({
setMembers(membershipData);
const driverRepo = getDriverRepository();
const driverData = await Promise.all(
membershipData.map(m => driverRepo.findById(m.driverId))
const driverEntities = await Promise.all(
membershipData.map((m) => driverRepo.findById(m.driverId))
);
setDrivers(driverData.filter((d): d is Driver => d !== null));
const driverDtos = driverEntities
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
.filter((dto): dto is DriverDTO => dto !== null);
const byId: Record<string, DriverDTO> = {};
for (const dto of driverDtos) {
byId[dto.id] = dto;
}
setDriversById(byId);
} catch (error) {
console.error('Failed to load members:', error);
} finally {
@@ -53,7 +62,7 @@ export default function LeagueMembers({
}, [loadMembers]);
const getDriverName = (driverId: string): string => {
const driver = drivers.find(d => d.id === driverId);
const driver = driversById[driverId];
return driver?.name || 'Unknown Driver';
};
@@ -160,6 +169,13 @@ export default function LeagueMembers({
const cannotModify = member.role === 'owner';
const driverStats = getDriverStats(member.driverId);
const isTopPerformer = index < 3 && sortBy === 'rating';
const driver = driversById[member.driverId];
const roleLabel =
member.role.charAt(0).toUpperCase() + member.role.slice(1);
const ratingAndWinsMeta =
driverStats && typeof driverStats.rating === 'number'
? `Rating ${driverStats.rating}${driverStats.wins ?? 0} wins`
: null;
return (
<tr
@@ -168,12 +184,17 @@ export default function LeagueMembers({
>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<Link
href={`/drivers/${member.driverId}?from=league&leagueId=${leagueId}`}
className="text-white font-medium hover:text-primary-blue transition-colors"
>
{getDriverName(member.driverId)}
</Link>
{driver ? (
<DriverIdentity
driver={driver}
href={`/drivers/${member.driverId}?from=league-members&leagueId=${leagueId}`}
contextLabel={roleLabel}
meta={ratingAndWinsMeta}
size="md"
/>
) : (
<span className="text-white">Unknown Driver</span>
)}
{isCurrentUser && (
<span className="text-xs text-gray-500">(You)</span>
)}

View File

@@ -3,13 +3,13 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Race } from '@gridpilot/racing/domain/entities/Race';
import { getRaceRepository } from '@/lib/di-container';
import {
getCurrentDriverId,
isRegistered,
registerForRace,
withdrawFromRace,
} from '@/lib/racingLegacyFacade';
getRaceRepository,
getIsDriverRegisteredForRaceQuery,
getRegisterForRaceUseCase,
getWithdrawFromRaceUseCase,
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
interface LeagueScheduleProps {
leagueId: string;
@@ -23,7 +23,7 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const [registrationStates, setRegistrationStates] = useState<Record<string, boolean>>({});
const [processingRace, setProcessingRace] = useState<string | null>(null);
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
useEffect(() => {
loadRaces();
@@ -35,15 +35,24 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const raceRepo = getRaceRepository();
const allRaces = await raceRepo.findAll();
const leagueRaces = allRaces
.filter(race => race.leagueId === leagueId)
.sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime());
.filter((race) => race.leagueId === leagueId)
.sort(
(a, b) =>
new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(),
);
setRaces(leagueRaces);
// Load registration states
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
const states: Record<string, boolean> = {};
leagueRaces.forEach(race => {
states[race.id] = isRegistered(race.id, currentDriverId);
});
await Promise.all(
leagueRaces.map(async (race) => {
const registered = await isRegisteredQuery.execute({
raceId: race.id,
driverId: currentDriverId,
});
states[race.id] = registered;
}),
);
setRegistrationStates(states);
} catch (error) {
console.error('Failed to load races:', error);
@@ -63,8 +72,13 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
setProcessingRace(race.id);
try {
registerForRace(race.id, currentDriverId, leagueId);
setRegistrationStates(prev => ({ ...prev, [race.id]: true }));
const useCase = getRegisterForRaceUseCase();
await useCase.execute({
raceId: race.id,
leagueId,
driverId: currentDriverId,
});
setRegistrationStates((prev) => ({ ...prev, [race.id]: true }));
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register');
} finally {
@@ -83,8 +97,12 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
setProcessingRace(race.id);
try {
withdrawFromRace(race.id, currentDriverId);
setRegistrationStates(prev => ({ ...prev, [race.id]: false }));
const useCase = getWithdrawFromRaceUseCase();
await useCase.execute({
raceId: race.id,
driverId: currentDriverId,
});
setRegistrationStates((prev) => ({ ...prev, [race.id]: false }));
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw');
} finally {

View File

@@ -1,6 +1,7 @@
'use client';
import { getMembership, getCurrentDriverId, type MembershipRole } from '@/lib/racingLegacyFacade';
import { getMembership, type MembershipRole } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
interface MembershipStatusProps {
leagueId: string;
@@ -8,7 +9,7 @@ interface MembershipStatusProps {
}
export default function MembershipStatus({ leagueId, className = '' }: MembershipStatusProps) {
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
const membership = getMembership(leagueId, currentDriverId);
if (!membership) {

View File

@@ -1,19 +1,18 @@
'use client';
import Link from 'next/link';
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO';
interface StandingsTableProps {
standings: Standing[];
drivers: Driver[];
standings: LeagueDriverSeasonStatsDTO[];
drivers: DriverDTO[];
leagueId: string;
}
export default function StandingsTable({ standings, drivers, leagueId }: StandingsTableProps) {
const getDriverName = (driverId: string): string => {
const driver = drivers.find((d) => d.id === driverId);
return driver?.name || 'Unknown Driver';
const getDriver = (driverId: string): DriverDTO | undefined => {
return drivers.find((d) => d.id === driverId);
};
if (standings.length === 0) {
@@ -26,50 +25,121 @@ export default function StandingsTable({ standings, drivers, leagueId }: Standin
return (
<div className="overflow-x-auto">
<table className="w-full">
<table className="w-full text-sm">
<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">Points</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Wins</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Races</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Pos</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Driver</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Team</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Total Pts</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Pts / Race</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Started</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Finished</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">DNF</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">NoShows</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Penalty</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Bonus</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Avg Finish</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Rating Δ</th>
</tr>
</thead>
<tbody>
{standings.map((standing) => {
const isLeader = standing.position === 1;
{standings.map((row) => {
const isLeader = row.position === 1;
const driver = getDriver(row.driverId);
const totalPointsLine =
row.penaltyPoints > 0
? `Total Points: ${row.totalPoints} (-${row.penaltyPoints} penalty)`
: `Total Points: ${row.totalPoints}`;
const ratingDelta =
row.ratingChange === null || row.ratingChange === 0
? '—'
: row.ratingChange > 0
? `+${row.ratingChange}`
: `${row.ratingChange}`;
return (
<tr
key={`${standing.leagueId}-${standing.driverId}`}
key={`${row.leagueId}-${row.driverId}`}
className="border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors"
>
<td className="py-3 px-4">
<span className={`font-semibold ${isLeader ? 'text-yellow-500' : 'text-white'}`}>
{standing.position}
{row.position}
</span>
</td>
<td className="py-3 px-4">
<Link
href={`/drivers/${standing.driverId}?from=league&leagueId=${leagueId}`}
className={
isLeader
? 'text-white font-semibold hover:text-primary-blue transition-colors'
: 'text-white hover:text-primary-blue transition-colors'
}
>
{getDriverName(standing.driverId)}
</Link>
{driver ? (
<DriverIdentity
driver={driver}
href={`/drivers/${row.driverId}?from=league-standings&leagueId=${leagueId}`}
contextLabel={`P${row.position}`}
size="sm"
meta={totalPointsLine}
/>
) : (
<span className="text-white">Unknown Driver</span>
)}
</td>
<td className="py-3 px-4">
<span className="text-white font-medium">{standing.points}</span>
<span className="text-gray-300">
{row.teamName ?? '—'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{standing.wins}</span>
<div className="flex flex-col">
<span className="text-white font-medium">{row.totalPoints}</span>
{row.penaltyPoints > 0 || row.bonusPoints !== 0 ? (
<span className="text-xs text-gray-400">
base {row.basePoints}
{row.penaltyPoints > 0 && (
<span className="text-red-400"> {row.penaltyPoints}</span>
)}
{row.bonusPoints !== 0 && (
<span className="text-green-400"> +{row.bonusPoints}</span>
)}
</span>
) : null}
</div>
</td>
<td className="py-3 px-4">
<span className="text-white">{standing.racesCompleted}</span>
<span className="text-white">
{row.pointsPerRace.toFixed(2)}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{row.racesStarted}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{row.racesFinished}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{row.dnfs}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{row.noShows}</span>
</td>
<td className="py-3 px-4">
<span className={row.penaltyPoints > 0 ? 'text-red-400' : 'text-gray-300'}>
{row.penaltyPoints > 0 ? `-${row.penaltyPoints}` : '—'}
</span>
</td>
<td className="py-3 px-4">
<span className={row.bonusPoints !== 0 ? 'text-green-400' : 'text-gray-300'}>
{row.bonusPoints !== 0 ? `+${row.bonusPoints}` : '—'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white">
{row.avgFinish !== null ? row.avgFinish.toFixed(2) : '—'}
</span>
</td>
<td className="py-3 px-4">
<span className={row.ratingChange && row.ratingChange > 0 ? 'text-green-400' : row.ratingChange && row.ratingChange < 0 ? 'text-red-400' : 'text-gray-300'}>
{ratingDelta}
</span>
</td>
</tr>
);

View File

@@ -0,0 +1,28 @@
'use client';
import { Star, Trophy } from 'lucide-react';
interface DriverRatingProps {
rating: number | null;
rank: number | null;
}
export default function DriverRating({ rating, rank }: DriverRatingProps) {
return (
<div className="mt-0.5 flex items-center gap-2 text-[11px]">
<span className="inline-flex items-center gap-1 text-amber-300">
<Star className="h-3 w-3" />
<span className="tabular-nums">
{rating !== null ? rating : '—'}
</span>
</span>
{rank !== null && (
<span className="inline-flex items-center gap-1 text-primary-blue">
<Trophy className="h-3 w-3" />
<span className="tabular-nums">#{rank}</span>
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import DriverRating from '@/components/profile/DriverRatingPill';
import { getImageService } from '@/lib/di-container';
export interface DriverSummaryPillProps {
driver: DriverDTO;
rating: number | null;
rank: number | null;
avatarSrc?: string;
onClick?: () => void;
href?: string;
}
export default function DriverSummaryPill(props: DriverSummaryPillProps) {
const { driver, rating, rank, avatarSrc, onClick, href } = props;
const resolvedAvatar =
avatarSrc ?? getImageService().getDriverAvatar(driver.id);
const content = (
<>
<div className="w-8 h-8 rounded-full overflow-hidden bg-charcoal-outline flex items-center justify-center border border-charcoal-outline/80">
<Image
src={resolvedAvatar}
alt={driver.name}
width={32}
height={32}
className="w-full h-full object-cover"
/>
</div>
<div className="flex flex-col leading-tight text-left">
<span className="text-xs font-semibold text-white truncate max-w-[140px]">
{driver.name}
</span>
<DriverRating rating={rating} rank={rank} />
</div>
</>
);
if (href) {
return (
<Link
href={href}
className="flex items-center gap-3 rounded-full bg-iron-gray/70 px-3 py-1.5 border border-charcoal-outline/80 shadow-[0_0_18px_rgba(0,0,0,0.45)] hover:border-primary-blue/60 hover:bg-iron-gray transition-colors"
>
{content}
</Link>
);
}
if (onClick) {
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-3 rounded-full bg-iron-gray/70 px-3 py-1.5 border border-charcoal-outline/80 shadow-[0_0_18px_rgba(0,0,0,0.45)] hover:border-primary-blue/60 hover:bg-iron-gray transition-colors"
>
{content}
</button>
);
}
return (
<div className="flex items-center gap-3 rounded-full bg-iron-gray/70 px-3 py-1.5 border border-charcoal-outline/80">
{content}
</div>
);
}

View File

@@ -3,21 +3,34 @@
import Image from 'next/image';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import Button from '../ui/Button';
import { getDriverTeam, getDriverAvatarUrl } from '@/lib/racingLegacyFacade';
import { getImageService } from '@/lib/di-container';
import DriverRatingPill from '@/components/profile/DriverRatingPill';
interface ProfileHeaderProps {
driver: DriverDTO;
rating?: number | null;
rank?: number | null;
isOwnProfile?: boolean;
onEditClick?: () => void;
teamName?: string | null;
teamTag?: string | null;
}
export default function ProfileHeader({ driver, isOwnProfile = false, onEditClick }: ProfileHeaderProps) {
export default function ProfileHeader({
driver,
rating,
rank,
isOwnProfile = false,
onEditClick,
teamName,
teamTag,
}: ProfileHeaderProps) {
return (
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-blue to-purple-600 overflow-hidden flex items-center justify-center">
<Image
src={getDriverAvatarUrl(driver.id)}
src={getImageService().getDriverAvatar(driver.id)}
alt={driver.name}
width={80}
height={80}
@@ -31,36 +44,30 @@ export default function ProfileHeader({ driver, isOwnProfile = false, onEditClic
<span className="text-3xl" aria-label={`Country: ${driver.country}`}>
{getCountryFlag(driver.country)}
</span>
{(() => {
const teamData = getDriverTeam(driver.id);
if (teamData) {
return (
<span className="px-3 py-1 bg-primary-blue/20 text-primary-blue rounded-full text-sm font-medium">
{teamData.team.tag}
</span>
);
}
return null;
})()}
{teamTag && (
<span className="px-3 py-1 bg-primary-blue/20 text-primary-blue rounded-full text-sm font-medium">
{teamTag}
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span>iRacing ID: {driver.iracingId}</span>
<span></span>
<span>Rating: 1450</span>
{(() => {
const teamData = getDriverTeam(driver.id);
if (teamData) {
return (
<>
<span></span>
<span className="text-primary-blue">{teamData.team.name}</span>
</>
);
}
return null;
})()}
{teamName && (
<>
<span></span>
<span className="text-primary-blue">
{teamTag ? `[${teamTag}] ${teamName}` : teamName}
</span>
</>
)}
</div>
{(typeof rating === 'number' || typeof rank === 'number') && (
<div className="mt-2">
<DriverRatingPill rating={rating ?? null} rank={rank ?? null} />
</div>
)}
</div>
</div>

View File

@@ -0,0 +1,171 @@
'use client';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { LogOut, Star } from 'lucide-react';
import { useAuth } from '@/lib/auth/AuthContext';
import {
getDriverStats,
getLeagueRankings,
getAllDriverRankings,
getDriverRepository,
getImageService,
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
export default function UserPill() {
const { session, login } = useAuth();
const [driver, setDriver] = useState<DriverDTO | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const user = session?.user as
| {
id: string;
displayName?: string;
primaryDriverId?: string | null;
avatarUrl?: string | null;
}
| undefined;
const primaryDriverId = useEffectiveDriverId();
useEffect(() => {
let cancelled = false;
async function loadDriver() {
if (!primaryDriverId) {
if (!cancelled) {
setDriver(null);
}
return;
}
const repo = getDriverRepository();
const entity = await repo.findById(primaryDriverId);
if (!cancelled) {
setDriver(EntityMappers.toDriverDTO(entity));
}
}
loadDriver();
return () => {
cancelled = true;
};
}, [primaryDriverId]);
const data = useMemo(() => {
if (!session?.user || !primaryDriverId || !driver) {
return null;
}
const driverStats = getDriverStats(primaryDriverId);
const allRankings = getAllDriverRankings();
let rating: number | null = driverStats?.rating ?? null;
let rank: number | null = null;
let totalDrivers: number | null = null;
if (driverStats) {
totalDrivers = allRankings.length || null;
if (typeof driverStats.overallRank === 'number' && driverStats.overallRank > 0) {
rank = driverStats.overallRank;
} else {
const indexInGlobal = allRankings.findIndex(
(stat) => stat.driverId === driverStats.driverId,
);
if (indexInGlobal !== -1) {
rank = indexInGlobal + 1;
}
}
if (rating === null) {
const globalEntry = allRankings.find(
(stat) => stat.driverId === driverStats.driverId,
);
if (globalEntry) {
rating = globalEntry.rating;
}
}
}
const avatarSrc = getImageService().getDriverAvatar(primaryDriverId);
return {
driver,
avatarSrc,
rating,
rank,
};
}, [session, driver, primaryDriverId]);
if (!session) {
const loginHref = '/auth/iracing/start?returnTo=/dashboard';
return (
<div className="flex items-center">
<Link
href={loginHref}
className="inline-flex items-center gap-2 rounded-full bg-primary-blue px-4 py-1.5 text-xs font-semibold text-white shadow-[0_0_12px_rgba(25,140,255,0.5)] hover:bg-primary-blue/90 hover:shadow-[0_0_18px_rgba(25,140,255,0.8)] transition-all"
>
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-white/10">
<Star className="h-3 w-3 text-amber-300" />
</span>
<span>Authenticate with iRacing</span>
</Link>
</div>
);
}
if (!data) {
return null;
}
return (
<div className="relative inline-flex items-center">
<DriverSummaryPill
driver={data.driver}
rating={data.rating}
rank={data.rank}
avatarSrc={data.avatarSrc}
onClick={() => setIsMenuOpen((open) => !open)}
/>
{isMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-48 rounded-lg bg-deep-graphite border border-charcoal-outline shadow-lg z-50">
<div className="py-1 text-sm text-gray-200">
<Link
href="/profile"
className="block px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Profile
</Link>
<Link
href="/profile/leagues"
className="block px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Manage leagues
</Link>
</div>
<div className="border-t border-charcoal-outline">
<form action="/auth/logout" method="POST">
<button
type="submit"
className="flex w-full items-center justify-between px-3 py-2 text-sm text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<span>Logout</span>
<LogOut className="h-4 w-4" />
</button>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -4,7 +4,8 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { createTeam, getCurrentDriverId } from '@/lib/racingLegacyFacade';
import { getCreateTeamUseCase } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
interface CreateTeamFormProps {
onCancel?: () => void;
@@ -20,6 +21,7 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const currentDriverId = useEffectiveDriverId();
const validateForm = () => {
const newErrors: Record<string, string> = {};
@@ -56,18 +58,21 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
setSubmitting(true);
try {
getCurrentDriverId(); // ensure identity initialized
const team = createTeam({
const useCase = getCreateTeamUseCase();
const result = await useCase.execute({
name: formData.name,
tag: formData.tag.toUpperCase(),
description: formData.description,
ownerId: currentDriverId,
leagues: [],
});
const teamId = result.team.id;
if (onSuccess) {
onSuccess(team.id);
onSuccess(teamId);
} else {
router.push(`/teams/${team.id}`);
router.push(`/teams/${teamId}`);
}
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to create team');

View File

@@ -1,15 +1,15 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import Button from '@/components/ui/Button';
import {
getCurrentDriverId,
getTeamMembership,
getDriverTeam,
joinTeam,
requestToJoinTeam,
leaveTeam,
} from '@/lib/racingLegacyFacade';
getJoinTeamUseCase,
getLeaveTeamUseCase,
getGetDriverTeamQuery,
getTeamMembershipRepository,
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { TeamMembership } from '@gridpilot/racing';
interface JoinTeamButtonProps {
teamId: string;
@@ -23,18 +23,50 @@ export default function JoinTeamButton({
onUpdate,
}: JoinTeamButtonProps) {
const [loading, setLoading] = useState(false);
const currentDriverId = getCurrentDriverId();
const membership = getTeamMembership(teamId, currentDriverId);
const currentTeam = getDriverTeam(currentDriverId);
const currentDriverId = useEffectiveDriverId();
const [membership, setMembership] = useState<TeamMembership | null>(null);
const [currentTeamName, setCurrentTeamName] = useState<string | null>(null);
const [currentTeamId, setCurrentTeamId] = useState<string | null>(null);
useEffect(() => {
const load = async () => {
const membershipRepo = getTeamMembershipRepository();
const m = await membershipRepo.getMembership(teamId, currentDriverId);
setMembership(m);
const driverTeamQuery = getGetDriverTeamQuery();
const driverTeam = await driverTeamQuery.execute({ driverId: currentDriverId });
if (driverTeam) {
setCurrentTeamId(driverTeam.team.id);
setCurrentTeamName(driverTeam.team.name);
} else {
setCurrentTeamId(null);
setCurrentTeamName(null);
}
};
void load();
}, [teamId, currentDriverId]);
const handleJoin = async () => {
setLoading(true);
try {
if (requiresApproval) {
requestToJoinTeam(teamId, currentDriverId);
const membershipRepo = getTeamMembershipRepository();
const existing = await membershipRepo.getMembership(teamId, currentDriverId);
if (existing) {
throw new Error('Already a member or have a pending request');
}
await membershipRepo.saveJoinRequest({
id: `team-request-${Date.now()}`,
teamId,
driverId: currentDriverId,
requestedAt: new Date(),
});
alert('Join request sent! Wait for team approval.');
} else {
joinTeam(teamId, currentDriverId);
const useCase = getJoinTeamUseCase();
await useCase.execute({ teamId, driverId: currentDriverId });
alert('Successfully joined team!');
}
onUpdate?.();
@@ -52,7 +84,8 @@ export default function JoinTeamButton({
setLoading(true);
try {
leaveTeam(teamId, currentDriverId);
const useCase = getLeaveTeamUseCase();
await useCase.execute({ teamId, driverId: currentDriverId });
alert('Successfully left team');
onUpdate?.();
} catch (error) {
@@ -84,10 +117,10 @@ export default function JoinTeamButton({
}
// Already on another team
if (currentTeam && currentTeam.team.id !== teamId) {
if (currentTeamId && currentTeamId !== teamId) {
return (
<Button variant="secondary" disabled>
Already on {currentTeam.team.name}
Already on {currentTeamName}
</Button>
);
}

View File

@@ -4,17 +4,16 @@ import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { getDriverRepository } from '@/lib/di-container';
import {
getDriverRepository,
getGetTeamJoinRequestsQuery,
getApproveTeamJoinRequestUseCase,
getRejectTeamJoinRequestUseCase,
getUpdateTeamUseCase,
} from '@/lib/di-container';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import {
Team,
TeamJoinRequest,
getTeamJoinRequests,
approveTeamJoinRequest,
rejectTeamJoinRequest,
updateTeam,
} from '@/lib/racingLegacyFacade';
import type { Team, TeamJoinRequest } from '@gridpilot/racing';
interface TeamAdminProps {
team: Team;
@@ -33,11 +32,12 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
});
useEffect(() => {
loadJoinRequests();
void loadJoinRequests();
}, [team.id]);
const loadJoinRequests = async () => {
const requests = getTeamJoinRequests(team.id);
const query = getGetTeamJoinRequestsQuery();
const requests = await query.execute({ teamId: team.id });
setJoinRequests(requests);
const driverRepo = getDriverRepository();
@@ -60,7 +60,8 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
const handleApprove = async (requestId: string) => {
try {
approveTeamJoinRequest(requestId);
const useCase = getApproveTeamJoinRequestUseCase();
await useCase.execute({ requestId });
await loadJoinRequests();
onUpdate();
} catch (error) {
@@ -70,16 +71,26 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
const handleReject = async (requestId: string) => {
try {
rejectTeamJoinRequest(requestId);
const useCase = getRejectTeamJoinRequestUseCase();
await useCase.execute({ requestId });
await loadJoinRequests();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to reject request');
}
};
const handleSaveChanges = () => {
const handleSaveChanges = async () => {
try {
updateTeam(team.id, editedTeam, team.ownerId);
const useCase = getUpdateTeamUseCase();
await useCase.execute({
teamId: team.id,
updates: {
name: editedTeam.name,
tag: editedTeam.tag,
description: editedTeam.description,
},
updatedBy: team.ownerId,
});
setEditMode(false);
onUpdate();
} catch (error) {

View File

@@ -2,7 +2,7 @@
import Image from 'next/image';
import Card from '../ui/Card';
import { getTeamLogoUrl } from '@/lib/racingLegacyFacade';
import { getImageService } from '@/lib/di-container';
interface TeamCardProps {
id: string;
@@ -10,6 +10,9 @@ interface TeamCardProps {
logo?: string;
memberCount: number;
leagues: string[];
rating?: number | null;
totalWins?: number;
totalRaces?: number;
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
onClick?: () => void;
}
@@ -20,6 +23,9 @@ export default function TeamCard({
logo,
memberCount,
leagues,
rating,
totalWins,
totalRaces,
performanceLevel,
onClick,
}: TeamCardProps) {
@@ -40,7 +46,7 @@ export default function TeamCard({
<div className="flex items-start gap-4">
<div className="w-16 h-16 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
<Image
src={logo || getTeamLogoUrl(id)}
src={logo || getImageService().getTeamLogo(id)}
alt={name}
width={64}
height={64}
@@ -54,6 +60,11 @@ export default function TeamCard({
<p className="text-sm text-gray-400">
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</p>
{typeof rating === 'number' && (
<p className="text-xs text-primary-blue mt-1">
Team rating: <span className="font-semibold">{Math.round(rating)}</span>
</p>
)}
</div>
</div>
@@ -69,6 +80,27 @@ export default function TeamCard({
</div>
)}
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-sm text-gray-400">Rating</div>
<div className="text-lg font-semibold text-primary-blue">
{typeof rating === 'number' ? Math.round(rating) : '—'}
</div>
</div>
<div>
<div className="text-sm text-gray-400">Wins</div>
<div className="text-lg font-semibold text-green-400">
{totalWins ?? 0}
</div>
</div>
<div>
<div className="text-sm text-gray-400">Races</div>
<div className="text-lg font-semibold text-white">
{totalRaces ?? 0}
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-gray-400">Active in:</p>
<div className="flex flex-wrap gap-2">

View File

@@ -0,0 +1,78 @@
'use client';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { getImageService } from '@/lib/di-container';
export interface TeamLadderRowProps {
rank: number;
teamId: string;
teamName: string;
teamLogoUrl?: string;
memberCount: number;
teamRating: number | null;
totalWins: number;
totalRaces: number;
}
export default function TeamLadderRow({
rank,
teamId,
teamName,
teamLogoUrl,
memberCount,
teamRating,
totalWins,
totalRaces,
}: TeamLadderRowProps) {
const router = useRouter();
const imageService = getImageService();
const logo = teamLogoUrl ?? imageService.getTeamLogo(teamId);
const handleClick = () => {
router.push(`/teams/${teamId}`);
};
return (
<tr
onClick={handleClick}
className="cursor-pointer border-b border-charcoal-outline/60 hover:bg-iron-gray/30 transition-colors"
>
<td className="py-3 px-4 text-sm text-gray-300 font-semibold">#{rank}</td>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-md overflow-hidden bg-charcoal-outline flex-shrink-0">
<Image
src={logo}
alt={teamName}
width={32}
height={32}
className="w-full h-full object-cover"
/>
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold text-white truncate">
{teamName}
</span>
</div>
</div>
</td>
<td className="py-3 px-4 text-sm">
<span className="text-primary-blue font-semibold">
{teamRating !== null ? Math.round(teamRating) : '—'}
</span>
</td>
<td className="py-3 px-4 text-sm">
<span className="text-green-400 font-semibold">{totalWins}</span>
</td>
<td className="py-3 px-4 text-sm">
<span className="text-white">{totalRaces}</span>
</td>
<td className="py-3 px-4 text-sm">
<span className="text-gray-300">
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</span>
</td>
</tr>
);
}

View File

@@ -2,10 +2,11 @@
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { TeamMembership, TeamRole } from '@/lib/racingLegacyFacade';
import type { TeamMembership, TeamRole } from '@gridpilot/racing';
interface TeamRosterProps {
teamId: string;
@@ -33,7 +34,7 @@ export default function TeamRoster({
const driverMap: Record<string, DriverDTO> = {};
for (const membership of memberships) {
const driver = allDrivers.find(d => d.id === membership.driverId);
const driver = allDrivers.find((d) => d.id === membership.driverId);
if (driver) {
const dto = EntityMappers.toDriverDTO(driver);
if (dto) {
@@ -41,12 +42,12 @@ export default function TeamRoster({
}
}
}
setDrivers(driverMap);
setLoading(false);
};
loadDrivers();
void loadDrivers();
}, [memberships]);
const getRoleBadgeColor = (role: TeamRole) => {
@@ -85,14 +86,15 @@ export default function TeamRoster({
}
});
const teamAverageRating = memberships.length > 0
? Math.round(
memberships.reduce((sum, m) => {
const stats = getDriverStats(m.driverId);
return sum + (stats?.rating || 0);
}, 0) / memberships.length
)
: 0;
const teamAverageRating =
memberships.length > 0
? Math.round(
memberships.reduce((sum, m) => {
const stats = getDriverStats(m.driverId);
return sum + (stats?.rating || 0);
}, 0) / memberships.length,
)
: 0;
if (loading) {
return (
@@ -108,10 +110,11 @@ export default function TeamRoster({
<div>
<h3 className="text-xl font-semibold text-white">Team Roster</h3>
<p className="text-sm text-gray-400 mt-1">
{memberships.length} {memberships.length === 1 ? 'member' : 'members'} Avg Rating: <span className="text-primary-blue font-medium">{teamAverageRating}</span>
{memberships.length} {memberships.length === 1 ? 'member' : 'members'} Avg Rating:{' '}
<span className="text-primary-blue font-medium">{teamAverageRating}</span>
</p>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-400">Sort by:</label>
<select
@@ -125,70 +128,67 @@ export default function TeamRoster({
</select>
</div>
</div>
<div className="space-y-3">
{sortedMemberships.map((membership) => {
const driver = drivers[membership.driverId];
const driverStats = getDriverStats(membership.driverId);
if (!driver) return null;
const canManageMembership = isAdmin && membership.role !== 'owner';
return (
<div
key={membership.driverId}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-charcoal-outline/60 transition-colors"
>
<div className="flex items-center gap-4 flex-1">
<div className="w-12 h-12 rounded-full bg-primary-blue/20 flex items-center justify-center text-lg font-bold text-white">
{driver.name.charAt(0)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-white font-medium">{driver.name}</h4>
<span className={`px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(membership.role)}`}>
{getRoleLabel(membership.role)}
</span>
</div>
<p className="text-sm text-gray-400">
{driver.country} Joined {new Date(membership.joinedAt).toLocaleDateString()}
</p>
</div>
<DriverIdentity
driver={driver}
href={`/drivers/${driver.id}?from=team&teamId=${teamId}`}
contextLabel={getRoleLabel(membership.role)}
meta={
<span>
{driver.country} Joined{' '}
{new Date(membership.joinedAt).toLocaleDateString()}
</span>
}
size="md"
/>
{driverStats && (
<div className="flex items-center gap-6 text-center">
<div>
<div className="text-lg font-bold text-primary-blue">{driverStats.rating}</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
<div>
<div className="text-sm text-gray-300">#{driverStats.overallRank}</div>
<div className="text-xs text-gray-500">Rank</div>
{driverStats && (
<div className="flex items-center gap-6 text-center">
<div>
<div className="text-lg font-bold text-primary-blue">
{driverStats.rating}
</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
)}
</div>
<div>
<div className="text-sm text-gray-300">#{driverStats.overallRank}</div>
<div className="text-xs text-gray-500">Rank</div>
</div>
</div>
)}
{isAdmin && membership.role !== 'owner' && (
{canManageMembership && (
<div className="flex items-center gap-2">
{onChangeRole && (
<select
className="px-3 py-2 bg-iron-gray border-0 rounded text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={membership.role}
onChange={(e) => onChangeRole(membership.driverId, e.target.value as TeamRole)}
>
<option value="driver">Driver</option>
<option value="manager">Manager</option>
</select>
)}
{onRemoveMember && (
<button
onClick={() => onRemoveMember(membership.driverId)}
className="px-3 py-2 bg-danger-red/20 hover:bg-danger-red/30 text-danger-red rounded text-sm font-medium transition-colors"
>
Remove
</button>
)}
<select
className="px-3 py-2 bg-iron-gray border-0 rounded text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={membership.role}
onChange={(e) =>
onChangeRole?.(membership.driverId, e.target.value as TeamRole)
}
>
<option value="driver">Driver</option>
<option value="manager">Manager</option>
</select>
<button
onClick={() => onRemoveMember?.(membership.driverId)}
className="px-3 py-2 bg-danger-red/20 hover:bg-danger-red/30 text-danger-red rounded text-sm font-medium transition-colors"
>
Remove
</button>
</div>
)}
</div>
@@ -197,9 +197,7 @@ export default function TeamRoster({
</div>
{memberships.length === 0 && (
<div className="text-center py-8 text-gray-400">
No team members yet.
</div>
<div className="text-center py-8 text-gray-400">No team members yet.</div>
)}
</Card>
);

View File

@@ -2,10 +2,9 @@
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import { getStandingRepository, getLeagueRepository } from '@/lib/di-container';
import { getStandingRepository, getLeagueRepository, getTeamMembershipRepository } from '@/lib/di-container';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
import { getTeamMembers } from '@/lib/racingLegacyFacade';
interface TeamStandingsProps {
teamId: string;
@@ -29,7 +28,8 @@ export default function TeamStandings({ teamId, leagues }: TeamStandingsProps) {
const loadStandings = async () => {
const standingRepo = getStandingRepository();
const leagueRepo = getLeagueRepository();
const members = getTeamMembers(teamId);
const teamMembershipRepo = getTeamMembershipRepository();
const members = await teamMembershipRepo.getTeamMembers(teamId);
const memberIds = members.map(m => m.driverId);
const teamStandings: TeamLeagueStanding[] = [];

View File

@@ -0,0 +1,182 @@
'use client';
import {
useEffect,
useRef,
type ReactNode,
type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
interface ModalProps {
title: string;
description?: string;
children?: ReactNode;
primaryActionLabel?: string;
secondaryActionLabel?: string;
onPrimaryAction?: () => void | Promise<void>;
onSecondaryAction?: () => void;
onOpenChange?: (open: boolean) => void;
isOpen: boolean;
}
/**
* Generic, accessible modal component with backdrop, focus management, and semantic structure.
* Controlled via the `isOpen` prop; callers handle URL state and routing.
*/
export default function Modal({
title,
description,
children,
primaryActionLabel,
secondaryActionLabel,
onPrimaryAction,
onSecondaryAction,
onOpenChange,
isOpen,
}: ModalProps) {
const dialogRef = useRef<HTMLDivElement | null>(null);
const previouslyFocusedElementRef = useRef<Element | null>(null);
// When the modal opens, remember previous focus and move focus into the dialog
useEffect(() => {
if (isOpen) {
previouslyFocusedElementRef.current = document.activeElement;
const focusable = getFirstFocusable(dialogRef.current);
if (focusable) {
focusable.focus();
} else if (dialogRef.current) {
dialogRef.current.focus();
}
return;
}
// When closing, restore focus
if (!isOpen && previouslyFocusedElementRef.current instanceof HTMLElement) {
previouslyFocusedElementRef.current.focus();
}
}, [isOpen]);
// Basic focus trap with keyboard handling (Tab / Shift+Tab, Escape)
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
if (onOpenChange) {
onOpenChange(false);
}
return;
}
if (event.key === 'Tab') {
const focusable = getFocusableElements(dialogRef.current);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
}
}
};
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget && onOpenChange) {
onOpenChange(false);
}
};
if (!isOpen) {
return null;
}
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby={description ? 'modal-description' : undefined}
onKeyDown={handleKeyDown}
onClick={handleBackdropClick}
>
<div
ref={dialogRef}
className="w-full max-w-md rounded-2xl bg-deep-graphite border border-charcoal-outline shadow-2xl outline-none"
tabIndex={-1}
>
<div className="px-6 pt-5 pb-3 border-b border-charcoal-outline/80">
<h2
id="modal-title"
className="text-lg font-semibold text-white"
>
{title}
</h2>
{description && (
<p
id="modal-description"
className="mt-2 text-sm text-gray-400"
>
{description}
</p>
)}
</div>
<div className="px-6 py-4 text-sm text-gray-100">{children}</div>
{(primaryActionLabel || secondaryActionLabel) && (
<div className="flex justify-end gap-3 px-6 py-4 border-t border-charcoal-outline/80">
{secondaryActionLabel && (
<button
type="button"
onClick={() => {
onSecondaryAction?.();
onOpenChange?.(false);
}}
className="min-h-[40px] rounded-full px-4 py-2 text-sm font-medium text-gray-200 bg-iron-gray border border-charcoal-outline hover:bg-charcoal-outline transition-colors"
>
{secondaryActionLabel}
</button>
)}
{primaryActionLabel && (
<button
type="button"
onClick={async () => {
if (onPrimaryAction) {
await onPrimaryAction();
}
}}
className="min-h-[40px] rounded-full px-4 py-2 text-sm font-semibold text-white bg-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.4)] hover:shadow-[0_0_25px_rgba(25,140,255,0.6)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-blue transition-all"
>
{primaryActionLabel}
</button>
)}
</div>
)}
</div>
</div>
);
}
function getFocusableElements(root: HTMLElement | null): HTMLElement[] {
if (!root) return [];
const selectors = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
];
const nodes = Array.from(
root.querySelectorAll<HTMLElement>(selectors.join(',')),
);
return nodes.filter((el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'));
}
function getFirstFocusable(root: HTMLElement | null): HTMLElement | null {
const elements = getFocusableElements(root);
return elements[0] ?? null;
}

View File

@@ -0,0 +1,20 @@
'use client';
import { useAuth } from '@/lib/auth/AuthContext';
/**
* Returns the effective driver ID for the current session.
*
* Prefers the authenticated user's primaryDriverId when available,
* otherwise falls back to the demo default used across the alpha site.
*/
export function useEffectiveDriverId(): string {
const { session } = useAuth();
const user = session?.user as
| {
primaryDriverId?: string | null;
}
| undefined;
return user?.primaryDriverId ?? 'driver-1';
}

View File

@@ -16,19 +16,55 @@ import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/IL
import type { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRaceRepository';
import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository';
import type { IStandingRepository } from '@gridpilot/racing/domain/repositories/IStandingRepository';
import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository';
import type {
ITeamRepository,
ITeamMembershipRepository,
IRaceRegistrationRepository,
} from '@gridpilot/racing';
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import type { LeagueMembership, JoinRequest } from '@gridpilot/racing/domain/entities/LeagueMembership';
import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
import type { ImageServicePort } from '@gridpilot/media';
import { InMemoryDriverRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryDriverRepository';
import { InMemoryLeagueRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueRepository';
import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRepository';
import { InMemoryResultRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryResultRepository';
import { InMemoryStandingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryStandingRepository';
import { InMemoryPenaltyRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryPenaltyRepository';
import { InMemoryTeamRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamRepository';
import { InMemoryTeamMembershipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamMembershipRepository';
import { InMemoryRaceRegistrationRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRegistrationRepository';
import { InMemoryLeagueMembershipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueMembershipRepository';
import {
JoinLeagueUseCase,
RegisterForRaceUseCase,
WithdrawFromRaceUseCase,
IsDriverRegisteredForRaceQuery,
GetRaceRegistrationsQuery,
CreateTeamUseCase,
JoinTeamUseCase,
LeaveTeamUseCase,
ApproveTeamJoinRequestUseCase,
RejectTeamJoinRequestUseCase,
UpdateTeamUseCase,
GetAllTeamsQuery,
GetTeamDetailsQuery,
GetTeamMembersQuery,
GetTeamJoinRequestsQuery,
GetDriverTeamQuery,
GetLeagueStandingsQuery,
GetLeagueDriverSeasonStatsQuery,
GetAllLeaguesWithCapacityQuery,
} from '@gridpilot/racing/application';
import { createStaticRacingSeed, type RacingSeedData } from '@gridpilot/testing-support';
import {
InMemoryFeedRepository,
InMemorySocialGraphRepository,
} from '@gridpilot/social/infrastructure/inmemory/InMemorySocialAndFeed';
import { DemoImageServiceAdapter } from '@gridpilot/demo-infrastructure';
/**
* Seed data for development
@@ -97,12 +133,41 @@ class DIContainer {
private _raceRepository: IRaceRepository;
private _resultRepository: IResultRepository;
private _standingRepository: IStandingRepository;
private _penaltyRepository: IPenaltyRepository;
private _teamRepository: ITeamRepository;
private _teamMembershipRepository: ITeamMembershipRepository;
private _raceRegistrationRepository: IRaceRegistrationRepository;
private _leagueMembershipRepository: ILeagueMembershipRepository;
private _feedRepository: IFeedRepository;
private _socialRepository: ISocialGraphRepository;
private _imageService: ImageServicePort;
// Racing application use-cases / queries
private _joinLeagueUseCase: JoinLeagueUseCase;
private _registerForRaceUseCase: RegisterForRaceUseCase;
private _withdrawFromRaceUseCase: WithdrawFromRaceUseCase;
private _isDriverRegisteredForRaceQuery: IsDriverRegisteredForRaceQuery;
private _getRaceRegistrationsQuery: GetRaceRegistrationsQuery;
private _getLeagueStandingsQuery: GetLeagueStandingsQuery;
private _getLeagueDriverSeasonStatsQuery: GetLeagueDriverSeasonStatsQuery;
private _getAllLeaguesWithCapacityQuery: GetAllLeaguesWithCapacityQuery;
private _createTeamUseCase: CreateTeamUseCase;
private _joinTeamUseCase: JoinTeamUseCase;
private _leaveTeamUseCase: LeaveTeamUseCase;
private _approveTeamJoinRequestUseCase: ApproveTeamJoinRequestUseCase;
private _rejectTeamJoinRequestUseCase: RejectTeamJoinRequestUseCase;
private _updateTeamUseCase: UpdateTeamUseCase;
private _getAllTeamsQuery: GetAllTeamsQuery;
private _getTeamDetailsQuery: GetTeamDetailsQuery;
private _getTeamMembersQuery: GetTeamMembersQuery;
private _getTeamJoinRequestsQuery: GetTeamJoinRequestsQuery;
private _getDriverTeamQuery: GetDriverTeamQuery;
private constructor() {
// Create seed data
const seedData = createSeedData();
const primaryDriverId = seedData.drivers[0]?.id ?? 'driver-1';
// Initialize repositories with seed data
this._driverRepository = new InMemoryDriverRepository(seedData.drivers);
@@ -123,9 +188,228 @@ class DIContainer {
this._leagueRepository
);
// Race registrations (start empty; populated via use-cases)
this._raceRegistrationRepository = new InMemoryRaceRegistrationRepository();
// Penalties (seeded in-memory adapter)
this._penaltyRepository = new InMemoryPenaltyRepository();
// League memberships seeded from static memberships with guaranteed owner roles
const seededMemberships: LeagueMembership[] = seedData.memberships.map((m) => ({
leagueId: m.leagueId,
driverId: m.driverId,
role: 'member',
status: 'active',
joinedAt: new Date(),
}));
// Ensure each league owner has an owner membership
for (const league of seedData.leagues) {
const existing = seededMemberships.find(
(m) => m.leagueId === league.id && m.driverId === league.ownerId,
);
if (!existing) {
seededMemberships.push({
leagueId: league.id,
driverId: league.ownerId,
role: 'owner',
status: 'active',
joinedAt: new Date(),
});
} else {
existing.role = 'owner';
}
}
// Ensure the primary demo driver owns at least one league in memberships
const hasPrimaryOwnerMembership = seededMemberships.some(
(m: LeagueMembership) => m.driverId === primaryDriverId && m.role === 'owner',
);
if (!hasPrimaryOwnerMembership && seedData.leagues.length > 0) {
const targetLeague =
seedData.leagues.find((l) => l.ownerId === primaryDriverId) ??
seedData.leagues[0];
const existingForPrimary = seededMemberships.find(
(m) => m.leagueId === targetLeague.id && m.driverId === primaryDriverId,
);
if (existingForPrimary) {
existingForPrimary.role = 'owner';
} else {
seededMemberships.push({
leagueId: targetLeague.id,
driverId: primaryDriverId,
role: 'owner',
status: 'active',
joinedAt: new Date(),
});
}
}
// Seed sample league admins for the primary driver's league (alpha demo)
const primaryLeagueForAdmins =
seedData.leagues.find((l) => l.ownerId === primaryDriverId) ??
seedData.leagues[0];
if (primaryLeagueForAdmins) {
const adminCandidates = seedData.drivers
.filter((d) => d.id !== primaryLeagueForAdmins.ownerId)
.slice(0, 2);
adminCandidates.forEach((driver) => {
const existing = seededMemberships.find(
(m) =>
m.leagueId === primaryLeagueForAdmins.id && m.driverId === driver.id,
);
if (existing) {
if (existing.role !== 'owner') {
existing.role = 'admin';
}
} else {
seededMemberships.push({
leagueId: primaryLeagueForAdmins.id,
driverId: driver.id,
role: 'admin',
status: 'active',
joinedAt: new Date(),
});
}
});
}
// Seed a few pending join requests for demo leagues
const seededJoinRequests: JoinRequest[] = [];
const demoLeagues = seedData.leagues.slice(0, 2);
const extraDrivers = seedData.drivers.slice(3, 8);
demoLeagues.forEach((league) => {
extraDrivers.forEach((driver, index) => {
seededJoinRequests.push({
id: `join-${league.id}-${driver.id}`,
leagueId: league.id,
driverId: driver.id,
requestedAt: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000),
message:
index % 2 === 0
? 'Would love to race in this series!'
: 'Looking to join for the upcoming season.',
});
});
});
this._leagueMembershipRepository = new InMemoryLeagueMembershipRepository(
seededMemberships,
seededJoinRequests,
);
// Team repositories seeded from static memberships/teams
this._teamRepository = new InMemoryTeamRepository(
seedData.teams.map((t) => ({
id: t.id,
name: t.name,
tag: t.tag,
description: t.description,
ownerId: seedData.drivers[0]?.id ?? 'driver-1',
leagues: [t.primaryLeagueId],
createdAt: new Date(),
})),
);
this._teamMembershipRepository = new InMemoryTeamMembershipRepository(
seedData.memberships
.filter((m) => m.teamId)
.map((m) => ({
teamId: m.teamId!,
driverId: m.driverId,
role: 'driver',
status: 'active',
joinedAt: new Date(),
})),
);
// Application-layer use-cases and queries wired with repositories
this._joinLeagueUseCase = new JoinLeagueUseCase(this._leagueMembershipRepository);
this._registerForRaceUseCase = new RegisterForRaceUseCase(
this._raceRegistrationRepository,
this._leagueMembershipRepository,
);
this._withdrawFromRaceUseCase = new WithdrawFromRaceUseCase(
this._raceRegistrationRepository,
);
this._isDriverRegisteredForRaceQuery = new IsDriverRegisteredForRaceQuery(
this._raceRegistrationRepository,
);
this._getRaceRegistrationsQuery = new GetRaceRegistrationsQuery(
this._raceRegistrationRepository,
);
this._getLeagueStandingsQuery = new GetLeagueStandingsQuery(this._standingRepository);
this._getLeagueDriverSeasonStatsQuery = new GetLeagueDriverSeasonStatsQuery(
this._standingRepository,
this._resultRepository,
this._penaltyRepository,
{
getRating: (driverId: string) => {
const stats = driverStats[driverId];
if (!stats) {
return { rating: null, ratingChange: null };
}
// For alpha we expose current rating and a mock delta
const baseline = 1500;
const delta = stats.rating - baseline;
return {
rating: stats.rating,
ratingChange: delta !== 0 ? delta : null,
};
},
},
);
this._getAllLeaguesWithCapacityQuery = new GetAllLeaguesWithCapacityQuery(
this._leagueRepository,
this._leagueMembershipRepository,
);
this._createTeamUseCase = new CreateTeamUseCase(
this._teamRepository,
this._teamMembershipRepository,
);
this._joinTeamUseCase = new JoinTeamUseCase(
this._teamRepository,
this._teamMembershipRepository,
);
this._leaveTeamUseCase = new LeaveTeamUseCase(this._teamMembershipRepository);
this._approveTeamJoinRequestUseCase = new ApproveTeamJoinRequestUseCase(
this._teamMembershipRepository,
);
this._rejectTeamJoinRequestUseCase = new RejectTeamJoinRequestUseCase(
this._teamMembershipRepository,
);
this._updateTeamUseCase = new UpdateTeamUseCase(
this._teamRepository,
this._teamMembershipRepository,
);
this._getAllTeamsQuery = new GetAllTeamsQuery(this._teamRepository);
this._getTeamDetailsQuery = new GetTeamDetailsQuery(
this._teamRepository,
this._teamMembershipRepository,
);
this._getTeamMembersQuery = new GetTeamMembersQuery(this._teamMembershipRepository);
this._getTeamJoinRequestsQuery = new GetTeamJoinRequestsQuery(
this._teamMembershipRepository,
);
this._getDriverTeamQuery = new GetDriverTeamQuery(
this._teamRepository,
this._teamMembershipRepository,
);
// Social and feed adapters backed by static seed
this._feedRepository = new InMemoryFeedRepository(seedData);
this._socialRepository = new InMemorySocialGraphRepository(seedData);
// Image service backed by demo adapter
this._imageService = new DemoImageServiceAdapter();
}
/**
@@ -168,6 +452,102 @@ class DIContainer {
return this._standingRepository;
}
get penaltyRepository(): IPenaltyRepository {
return this._penaltyRepository;
}
get raceRegistrationRepository(): IRaceRegistrationRepository {
return this._raceRegistrationRepository;
}
get leagueMembershipRepository(): ILeagueMembershipRepository {
return this._leagueMembershipRepository;
}
get joinLeagueUseCase(): JoinLeagueUseCase {
return this._joinLeagueUseCase;
}
get registerForRaceUseCase(): RegisterForRaceUseCase {
return this._registerForRaceUseCase;
}
get withdrawFromRaceUseCase(): WithdrawFromRaceUseCase {
return this._withdrawFromRaceUseCase;
}
get isDriverRegisteredForRaceQuery(): IsDriverRegisteredForRaceQuery {
return this._isDriverRegisteredForRaceQuery;
}
get getRaceRegistrationsQuery(): GetRaceRegistrationsQuery {
return this._getRaceRegistrationsQuery;
}
get getLeagueStandingsQuery(): GetLeagueStandingsQuery {
return this._getLeagueStandingsQuery;
}
get getLeagueDriverSeasonStatsQuery(): GetLeagueDriverSeasonStatsQuery {
return this._getLeagueDriverSeasonStatsQuery;
}
get getAllLeaguesWithCapacityQuery(): GetAllLeaguesWithCapacityQuery {
return this._getAllLeaguesWithCapacityQuery;
}
get createTeamUseCase(): CreateTeamUseCase {
return this._createTeamUseCase;
}
get joinTeamUseCase(): JoinTeamUseCase {
return this._joinTeamUseCase;
}
get leaveTeamUseCase(): LeaveTeamUseCase {
return this._leaveTeamUseCase;
}
get approveTeamJoinRequestUseCase(): ApproveTeamJoinRequestUseCase {
return this._approveTeamJoinRequestUseCase;
}
get rejectTeamJoinRequestUseCase(): RejectTeamJoinRequestUseCase {
return this._rejectTeamJoinRequestUseCase;
}
get updateTeamUseCase(): UpdateTeamUseCase {
return this._updateTeamUseCase;
}
get getAllTeamsQuery(): GetAllTeamsQuery {
return this._getAllTeamsQuery;
}
get getTeamDetailsQuery(): GetTeamDetailsQuery {
return this._getTeamDetailsQuery;
}
get getTeamMembersQuery(): GetTeamMembersQuery {
return this._getTeamMembersQuery;
}
get getTeamJoinRequestsQuery(): GetTeamJoinRequestsQuery {
return this._getTeamJoinRequestsQuery;
}
get getDriverTeamQuery(): GetDriverTeamQuery {
return this._getDriverTeamQuery;
}
get teamRepository(): ITeamRepository {
return this._teamRepository;
}
get teamMembershipRepository(): ITeamMembershipRepository {
return this._teamMembershipRepository;
}
get feedRepository(): IFeedRepository {
return this._feedRepository;
}
@@ -175,6 +555,10 @@ class DIContainer {
get socialRepository(): ISocialGraphRepository {
return this._socialRepository;
}
get imageService(): ImageServicePort {
return this._imageService;
}
}
/**
@@ -200,6 +584,102 @@ export function getStandingRepository(): IStandingRepository {
return DIContainer.getInstance().standingRepository;
}
export function getPenaltyRepository(): IPenaltyRepository {
return DIContainer.getInstance().penaltyRepository;
}
export function getRaceRegistrationRepository(): IRaceRegistrationRepository {
return DIContainer.getInstance().raceRegistrationRepository;
}
export function getLeagueMembershipRepository(): ILeagueMembershipRepository {
return DIContainer.getInstance().leagueMembershipRepository;
}
export function getJoinLeagueUseCase(): JoinLeagueUseCase {
return DIContainer.getInstance().joinLeagueUseCase;
}
export function getRegisterForRaceUseCase(): RegisterForRaceUseCase {
return DIContainer.getInstance().registerForRaceUseCase;
}
export function getWithdrawFromRaceUseCase(): WithdrawFromRaceUseCase {
return DIContainer.getInstance().withdrawFromRaceUseCase;
}
export function getIsDriverRegisteredForRaceQuery(): IsDriverRegisteredForRaceQuery {
return DIContainer.getInstance().isDriverRegisteredForRaceQuery;
}
export function getGetRaceRegistrationsQuery(): GetRaceRegistrationsQuery {
return DIContainer.getInstance().getRaceRegistrationsQuery;
}
export function getGetLeagueStandingsQuery(): GetLeagueStandingsQuery {
return DIContainer.getInstance().getLeagueStandingsQuery;
}
export function getGetLeagueDriverSeasonStatsQuery(): GetLeagueDriverSeasonStatsQuery {
return DIContainer.getInstance().getLeagueDriverSeasonStatsQuery;
}
export function getGetAllLeaguesWithCapacityQuery(): GetAllLeaguesWithCapacityQuery {
return DIContainer.getInstance().getAllLeaguesWithCapacityQuery;
}
export function getTeamRepository(): ITeamRepository {
return DIContainer.getInstance().teamRepository;
}
export function getTeamMembershipRepository(): ITeamMembershipRepository {
return DIContainer.getInstance().teamMembershipRepository;
}
export function getCreateTeamUseCase(): CreateTeamUseCase {
return DIContainer.getInstance().createTeamUseCase;
}
export function getJoinTeamUseCase(): JoinTeamUseCase {
return DIContainer.getInstance().joinTeamUseCase;
}
export function getLeaveTeamUseCase(): LeaveTeamUseCase {
return DIContainer.getInstance().leaveTeamUseCase;
}
export function getApproveTeamJoinRequestUseCase(): ApproveTeamJoinRequestUseCase {
return DIContainer.getInstance().approveTeamJoinRequestUseCase;
}
export function getRejectTeamJoinRequestUseCase(): RejectTeamJoinRequestUseCase {
return DIContainer.getInstance().rejectTeamJoinRequestUseCase;
}
export function getUpdateTeamUseCase(): UpdateTeamUseCase {
return DIContainer.getInstance().updateTeamUseCase;
}
export function getGetAllTeamsQuery(): GetAllTeamsQuery {
return DIContainer.getInstance().getAllTeamsQuery;
}
export function getGetTeamDetailsQuery(): GetTeamDetailsQuery {
return DIContainer.getInstance().getTeamDetailsQuery;
}
export function getGetTeamMembersQuery(): GetTeamMembersQuery {
return DIContainer.getInstance().getTeamMembersQuery;
}
export function getGetTeamJoinRequestsQuery(): GetTeamJoinRequestsQuery {
return DIContainer.getInstance().getTeamJoinRequestsQuery;
}
export function getGetDriverTeamQuery(): GetDriverTeamQuery {
return DIContainer.getInstance().getDriverTeamQuery;
}
export function getFeedRepository(): IFeedRepository {
return DIContainer.getInstance().feedRepository;
}
@@ -208,6 +688,10 @@ export function getSocialRepository(): ISocialGraphRepository {
return DIContainer.getInstance().socialRepository;
}
export function getImageService(): ImageServicePort {
return DIContainer.getInstance().imageService;
}
/**
* Reset function for testing
*/

View File

@@ -0,0 +1,89 @@
'use client';
import type {
LeagueMembership as DomainLeagueMembership,
MembershipRole,
MembershipStatus,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
import { leagues, memberships as seedMemberships } from '@gridpilot/testing-support';
/**
* Lightweight league membership model mirroring the domain type but with
* a stringified joinedAt for easier UI formatting.
*/
export interface LeagueMembership extends Omit<DomainLeagueMembership, 'joinedAt'> {
joinedAt: string;
}
const leagueMemberships = new Map<string, LeagueMembership[]>();
/**
* Initialize league memberships once from static seed data.
*
* - All seeded memberships become active members.
* - League owners are guaranteed to have an owner membership.
*/
(function initializeLeagueMembershipsFromSeed() {
if (leagueMemberships.size > 0) {
return;
}
const byLeague = new Map<string, LeagueMembership[]>();
for (const membership of seedMemberships) {
const list = byLeague.get(membership.leagueId) ?? [];
const joinedAt = new Date().toISOString();
list.push({
leagueId: membership.leagueId,
driverId: membership.driverId,
role: 'member',
status: 'active',
joinedAt,
});
byLeague.set(membership.leagueId, list);
}
for (const league of leagues) {
const list = byLeague.get(league.id) ?? [];
const existingOwner = list.find((m) => m.driverId === league.ownerId);
if (existingOwner) {
existingOwner.role = 'owner';
} else {
const joinedAt = new Date().toISOString();
list.unshift({
leagueId: league.id,
driverId: league.ownerId,
role: 'owner',
status: 'active',
joinedAt,
});
}
byLeague.set(league.id, list);
}
for (const [leagueId, list] of byLeague.entries()) {
leagueMemberships.set(leagueId, list);
}
})();
export function getMembership(leagueId: string, driverId: string): LeagueMembership | null {
const list = leagueMemberships.get(leagueId);
if (!list) return null;
return list.find((m) => m.driverId === driverId) ?? null;
}
export function getLeagueMembers(leagueId: string): LeagueMembership[] {
return [...(leagueMemberships.get(leagueId) ?? [])];
}
export function isOwnerOrAdmin(leagueId: string, driverId: string): boolean {
const membership = getMembership(leagueId, driverId);
if (!membership) return false;
return membership.role === 'owner' || membership.role === 'admin';
}
export type { MembershipRole, MembershipStatus };

View File

@@ -1,4 +1,4 @@
import type { MembershipRole } from '@/lib/racingLegacyFacade';
import type { MembershipRole } from '@gridpilot/racing/domain/entities/LeagueMembership';
export type LeagueRole = MembershipRole;

View File

@@ -1,558 +0,0 @@
/**
* Website-local racing façade
*
* This module provides synchronous helper functions used by the alpha website
* without depending on legacy exports from @gridpilot/racing/application.
* It maintains simple in-memory state for memberships, teams, and registrations.
*/
import type {
LeagueMembership as DomainLeagueMembership,
MembershipRole,
MembershipStatus,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
import { getDriverAvatar, getTeamLogo, getLeagueBanner, memberships as seedMemberships, leagues as seedLeagues } from '@gridpilot/testing-support';
export type { MembershipRole, MembershipStatus };
export interface LeagueMembership extends Omit<DomainLeagueMembership, 'joinedAt'> {
joinedAt: string;
}
// Lightweight league join request model for the website
export interface JoinRequest {
id: string;
leagueId: string;
driverId: string;
message?: string;
requestedAt: string;
}
import type {
Team,
TeamJoinRequest,
TeamMembership,
TeamRole,
TeamMembershipStatus,
} from '@gridpilot/racing/domain/entities/Team';
export type { Team, TeamJoinRequest, TeamMembership, TeamRole, TeamMembershipStatus };
/**
* Identity helpers
*
* For the alpha website we treat a single demo driver as the "current" user.
*/
const CURRENT_DRIVER_ID = 'driver-1';
export function getCurrentDriverId(): string {
return CURRENT_DRIVER_ID;
}
/**
* In-memory stores
*/
const leagueMemberships = new Map<string, LeagueMembership[]>();
const leagueJoinRequests = new Map<string, JoinRequest[]>();
const teams = new Map<string, Team>();
const teamMemberships = new Map<string, TeamMembership[]>();
const teamJoinRequests = new Map<string, TeamJoinRequest[]>();
const raceRegistrations = new Map<string, Set<string>>();
/**
* Helper utilities
*/
function ensureLeagueMembershipArray(leagueId: string): LeagueMembership[] {
let list = leagueMemberships.get(leagueId);
if (!list) {
list = [];
leagueMemberships.set(leagueId, list);
}
return list;
}
function ensureTeamMembershipArray(teamId: string): TeamMembership[] {
let list = teamMemberships.get(teamId);
if (!list) {
list = [];
teamMemberships.set(teamId, list);
}
return list;
}
function ensureRaceRegistrationSet(raceId: string): Set<string> {
let set = raceRegistrations.get(raceId);
if (!set) {
set = new Set<string>();
raceRegistrations.set(raceId, set);
}
return set;
}
let idCounter = 1;
function generateId(prefix: string): string {
return `${prefix}-${idCounter++}`;
}
// Initialize league memberships from static seed data
(function initializeLeagueMembershipsFromSeed() {
if (leagueMemberships.size > 0) {
return;
}
const membershipsByLeague = new Map<string, LeagueMembership[]>();
// Create base active memberships from seed
for (const membership of seedMemberships) {
const list = membershipsByLeague.get(membership.leagueId) ?? [];
const joinedAt = new Date(2024, 0, 1 + (idCounter % 28)).toISOString();
list.push({
leagueId: membership.leagueId,
driverId: membership.driverId,
role: 'member',
status: 'active',
joinedAt,
});
membershipsByLeague.set(membership.leagueId, list);
}
// Ensure league owners are represented as owners in memberships
for (const league of seedLeagues) {
const list = membershipsByLeague.get(league.id) ?? [];
const existingOwnerMembership = list.find((m) => m.driverId === league.ownerId);
if (existingOwnerMembership) {
existingOwnerMembership.role = 'owner';
} else {
const joinedAt = new Date(2024, 0, 1 + (idCounter % 28)).toISOString();
list.unshift({
leagueId: league.id,
driverId: league.ownerId,
role: 'owner',
status: 'active',
joinedAt,
});
}
membershipsByLeague.set(league.id, list);
}
// Store into facade-local maps
for (const [leagueId, list] of membershipsByLeague.entries()) {
leagueMemberships.set(leagueId, list);
}
})();
export function getDriverAvatarUrl(driverId: string): string {
return getDriverAvatar(driverId);
}
export function getTeamLogoUrl(teamId: string): string {
return getTeamLogo(teamId);
}
export function getLeagueBannerUrl(leagueId: string): string {
return getLeagueBanner(leagueId);
}
/**
* League membership API
*/
export function getMembership(leagueId: string, driverId: string): LeagueMembership | null {
const list = leagueMemberships.get(leagueId);
if (!list) return null;
return list.find((m) => m.driverId === driverId) ?? null;
}
export function getLeagueMembers(leagueId: string): LeagueMembership[] {
return [...(leagueMemberships.get(leagueId) ?? [])];
}
export function joinLeague(leagueId: string, driverId: string): void {
const existing = getMembership(leagueId, driverId);
if (existing && existing.status === 'active') {
throw new Error('Already a member of this league');
}
const list = ensureLeagueMembershipArray(leagueId);
const now = new Date();
if (existing) {
existing.status = 'active';
existing.joinedAt = now.toISOString();
return;
}
list.push({
leagueId,
driverId,
role: list.length === 0 ? 'owner' : 'member',
status: 'active',
joinedAt: now.toISOString(),
});
}
export function leaveLeague(leagueId: string, driverId: string): void {
const list = ensureLeagueMembershipArray(leagueId);
const membership = list.find((m) => m.driverId === driverId);
if (!membership) {
throw new Error('Not a member of this league');
}
if (membership.role === 'owner') {
throw new Error('League owner cannot leave the league');
}
const idx = list.indexOf(membership);
if (idx >= 0) {
list.splice(idx, 1);
}
}
export function requestToJoin(leagueId: string, driverId: string): void {
const existing = getMembership(leagueId, driverId);
if (existing && existing.status === 'active') {
throw new Error('Already a member of this league');
}
const requests = leagueJoinRequests.get(leagueId) ?? [];
const now = new Date().toISOString();
const request: JoinRequest = {
id: generateId('league-request'),
leagueId,
driverId,
requestedAt: now,
};
requests.push(request);
leagueJoinRequests.set(leagueId, requests);
}
export function isOwnerOrAdmin(leagueId: string, driverId: string): boolean {
const membership = getMembership(leagueId, driverId);
if (!membership) return false;
return membership.role === 'owner' || membership.role === 'admin';
}
/**
* League admin API (join requests and membership management)
*/
export function getJoinRequests(leagueId: string): JoinRequest[] {
return [...(leagueJoinRequests.get(leagueId) ?? [])];
}
export function approveJoinRequest(requestId: string): void {
for (const [leagueId, requests] of leagueJoinRequests.entries()) {
const idx = requests.findIndex((r) => r.id === requestId);
if (idx >= 0) {
const request = requests[idx];
requests.splice(idx, 1);
leagueJoinRequests.set(leagueId, requests);
joinLeague(leagueId, request.driverId);
return;
}
}
throw new Error('Join request not found');
}
export function rejectJoinRequest(requestId: string): void {
for (const [leagueId, requests] of leagueJoinRequests.entries()) {
const idx = requests.findIndex((r) => r.id === requestId);
if (idx >= 0) {
requests.splice(idx, 1);
leagueJoinRequests.set(leagueId, requests);
return;
}
}
throw new Error('Join request not found');
}
export function removeMember(leagueId: string, driverId: string, performedBy: string): void {
const performer = getMembership(leagueId, performedBy);
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
throw new Error('Only owners or admins can remove members');
}
const list = ensureLeagueMembershipArray(leagueId);
const membership = list.find((m) => m.driverId === driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot remove the league owner');
}
const idx = list.indexOf(membership);
if (idx >= 0) {
list.splice(idx, 1);
}
}
export function updateMemberRole(
leagueId: string,
driverId: string,
newRole: MembershipRole,
performedBy: string,
): void {
const performer = getMembership(leagueId, performedBy);
if (!performer || performer.role !== 'owner') {
throw new Error('Only the league owner can update roles');
}
const list = ensureLeagueMembershipArray(leagueId);
const membership = list.find((m) => m.driverId === driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot change the owner role');
}
membership.role = newRole;
}
/**
* Team API
*/
export function createTeam(initial: Pick<Team, 'name' | 'tag' | 'description' | 'leagues'>): Team {
const id = generateId('team');
const now = new Date();
const team: Team = {
id,
name: initial.name,
tag: initial.tag,
description: initial.description,
leagues: initial.leagues,
ownerId: CURRENT_DRIVER_ID,
createdAt: now,
};
teams.set(id, team);
const members = ensureTeamMembershipArray(id);
members.push({
teamId: id,
driverId: CURRENT_DRIVER_ID,
role: 'owner',
status: 'active',
joinedAt: now,
});
return team;
}
export function getAllTeams(): Team[] {
return [...teams.values()];
}
export function getTeam(teamId: string): Team | null {
return teams.get(teamId) ?? null;
}
export function updateTeam(teamId: string, updates: Partial<Pick<Team, 'name' | 'tag' | 'description' | 'leagues'>>, updatedBy: string): void {
const team = teams.get(teamId);
if (!team) {
throw new Error('Team not found');
}
const membership = getTeamMembership(teamId, updatedBy);
if (!membership || (membership.role !== 'owner' && membership.role !== 'manager')) {
throw new Error('Only owners or managers can update team');
}
teams.set(teamId, {
...team,
...updates,
});
}
export function getTeamMembers(teamId: string): TeamMembership[] {
return [...(teamMemberships.get(teamId) ?? [])];
}
export function getTeamMembership(teamId: string, driverId: string): TeamMembership | null {
const list = teamMemberships.get(teamId);
if (!list) return null;
return list.find((m) => m.driverId === driverId) ?? null;
}
export function getDriverTeam(driverId: string): { team: Team; membership: TeamMembership } | null {
for (const [teamId, memberships] of teamMemberships.entries()) {
const membership = memberships.find((m) => m.driverId === driverId && m.status === 'active');
if (membership) {
const team = teams.get(teamId);
if (team) {
return { team, membership };
}
}
}
return null;
}
export function isTeamOwnerOrManager(teamId: string, driverId: string): boolean {
const membership = getTeamMembership(teamId, driverId);
if (!membership) return false;
return membership.role === 'owner' || membership.role === 'manager';
}
export function joinTeam(teamId: string, driverId: string): void {
const team = teams.get(teamId);
if (!team) {
throw new Error('Team not found');
}
const existing = getTeamMembership(teamId, driverId);
if (existing && existing.status === 'active') {
throw new Error('Already a member of this team');
}
const list = ensureTeamMembershipArray(teamId);
const now = new Date();
if (existing) {
existing.status = 'active';
existing.joinedAt = now;
return;
}
list.push({
teamId,
driverId,
role: list.length === 0 ? 'owner' : 'driver',
status: 'active',
joinedAt: now,
});
}
export function leaveTeam(teamId: string, driverId: string): void {
const list = ensureTeamMembershipArray(teamId);
const membership = list.find((m) => m.driverId === driverId);
if (!membership) {
throw new Error('Not a member of this team');
}
if (membership.role === 'owner') {
throw new Error('Team owner cannot leave the team');
}
const idx = list.indexOf(membership);
if (idx >= 0) {
list.splice(idx, 1);
}
}
export function requestToJoinTeam(teamId: string, driverId: string, message?: string): void {
const existing = getTeamMembership(teamId, driverId);
if (existing && existing.status === 'active') {
throw new Error('Already a member of this team');
}
const requests = teamJoinRequests.get(teamId) ?? [];
const now = new Date();
const request: TeamJoinRequest = {
id: generateId('team-request'),
teamId,
driverId,
message,
requestedAt: now,
};
requests.push(request);
teamJoinRequests.set(teamId, requests);
}
export function getTeamJoinRequests(teamId: string): TeamJoinRequest[] {
return [...(teamJoinRequests.get(teamId) ?? [])];
}
export function approveTeamJoinRequest(requestId: string): void {
for (const [teamId, requests] of teamJoinRequests.entries()) {
const idx = requests.findIndex((r) => r.id === requestId);
if (idx >= 0) {
const request = requests[idx];
requests.splice(idx, 1);
teamJoinRequests.set(teamId, requests);
joinTeam(teamId, request.driverId);
return;
}
}
throw new Error('Join request not found');
}
export function rejectTeamJoinRequest(requestId: string): void {
for (const [teamId, requests] of teamJoinRequests.entries()) {
const idx = requests.findIndex((r) => r.id === requestId);
if (idx >= 0) {
requests.splice(idx, 1);
teamJoinRequests.set(teamId, requests);
return;
}
}
throw new Error('Join request not found');
}
export function removeTeamMember(teamId: string, driverId: string, performedBy: string): void {
const performerMembership = getTeamMembership(teamId, performedBy);
if (!performerMembership || (performerMembership.role !== 'owner' && performerMembership.role !== 'manager')) {
throw new Error('Only owners or managers can remove members');
}
const list = ensureTeamMembershipArray(teamId);
const membership = list.find((m) => m.driverId === driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot remove the team owner');
}
const idx = list.indexOf(membership);
if (idx >= 0) {
list.splice(idx, 1);
}
}
export function updateTeamMemberRole(teamId: string, driverId: string, newRole: TeamRole, performedBy: string): void {
const performerMembership = getTeamMembership(teamId, performedBy);
if (!performerMembership || (performerMembership.role !== 'owner' && performerMembership.role !== 'manager')) {
throw new Error('Only owners or managers can update roles');
}
const membership = getTeamMembership(teamId, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot change the owner role');
}
membership.role = newRole;
}
/**
* Race registration API
*/
export function isRegistered(raceId: string, driverId: string): boolean {
const set = raceRegistrations.get(raceId);
if (!set) return false;
return set.has(driverId);
}
export function registerForRace(raceId: string, driverId: string, _leagueId: string): void {
const set = ensureRaceRegistrationSet(raceId);
if (set.has(driverId)) {
throw new Error('Already registered for this race');
}
set.add(driverId);
}
export function withdrawFromRace(raceId: string, driverId: string): void {
const set = raceRegistrations.get(raceId);
if (!set || !set.has(driverId)) {
throw new Error('Not registered for this race');
}
set.delete(driverId);
}
export function getRegisteredDrivers(raceId: string): string[] {
const set = raceRegistrations.get(raceId);
if (!set) return [];
return [...set.values()];
}

View File

@@ -12,12 +12,13 @@
},
"dependencies": {
"@faker-js/faker": "^9.2.0",
"@vercel/kv": "^3.0.0",
"@gridpilot/identity": "0.1.0",
"@gridpilot/racing": "0.1.0",
"@gridpilot/social": "0.1.0",
"@gridpilot/testing-support": "0.1.0",
"@vercel/kv": "^3.0.0",
"framer-motion": "^12.23.25",
"lucide-react": "^0.555.0",
"next": "^15.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",

View File

@@ -23,7 +23,9 @@
"@gridpilot/identity/*": ["../../packages/identity/*"],
"@gridpilot/racing/*": ["../../packages/racing/*"],
"@gridpilot/social/*": ["../../packages/social/*"],
"@gridpilot/testing-support": ["../../packages/testing-support"]
"@gridpilot/testing-support": ["../../packages/testing-support"],
"@gridpilot/media": ["../../packages/media"],
"@gridpilot/demo-infrastructure": ["../../packages/demo-infrastructure"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],

19
package-lock.json generated
View File

@@ -146,6 +146,7 @@
"@gridpilot/testing-support": "0.1.0",
"@vercel/kv": "^3.0.0",
"framer-motion": "^12.23.25",
"lucide-react": "^0.555.0",
"next": "^15.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
@@ -1546,7 +1547,7 @@
"link": true
},
"node_modules/@gridpilot/testing-support": {
"resolved": "packages/demo-support",
"resolved": "packages/testing-support",
"link": true
},
"node_modules/@gridpilot/website": {
@@ -8611,6 +8612,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.555.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
"integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/luxon": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
@@ -13426,7 +13436,8 @@
},
"packages/demo-support": {
"name": "@gridpilot/testing-support",
"version": "0.1.0"
"version": "0.1.0",
"extraneous": true
},
"packages/identity": {
"name": "@gridpilot/identity",
@@ -13496,6 +13507,10 @@
"@gridpilot/racing": "0.1.0",
"@gridpilot/social": "0.1.0"
}
},
"packages/testing-support": {
"name": "@gridpilot/testing-support",
"version": "0.1.0"
}
}
}

View File

@@ -0,0 +1 @@
export * from './media/DemoImageServiceAdapter';

View File

@@ -0,0 +1,31 @@
import type { ImageServicePort } from '@gridpilot/media';
export class DemoImageServiceAdapter implements ImageServicePort {
getDriverAvatar(driverId: string): string {
const seed = stableHash(driverId);
return `https://picsum.photos/seed/driver-${seed}/128/128`;
}
getTeamLogo(teamId: string): string {
const seed = stableHash(teamId);
return `https://picsum.photos/seed/team-${seed}/256/256`;
}
getLeagueCover(leagueId: string): string {
const seed = stableHash(leagueId);
return `https://picsum.photos/seed/league-cover-${seed}/1200/280?blur=2`;
}
getLeagueLogo(leagueId: string): string {
const seed = stableHash(leagueId);
return `https://picsum.photos/seed/league-logo-${seed}/160/160`;
}
}
function stableHash(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash * 31 + value.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}

View File

@@ -0,0 +1,15 @@
{
"name": "@gridpilot/demo-infrastructure",
"version": "0.1.0",
"type": "module",
"main": "./index.ts",
"types": "./index.ts",
"dependencies": {
"@gridpilot/media": "file:../media",
"@faker-js/faker": "^9.0.0"
},
"exports": {
".": "./index.ts",
"./media/*": "./media/*"
}
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "../..",
"outDir": "dist",
"declaration": true,
"declarationMap": false
},
"include": [
"../../packages/demo-infrastructure/**/*.ts",
"../../packages/media/**/*.ts"
]
}

View File

@@ -0,0 +1,6 @@
export interface ImageServicePort {
getDriverAvatar(driverId: string): string;
getTeamLogo(teamId: string): string;
getLeagueCover(leagueId: string): string;
getLeagueLogo(leagueId: string): string;
}

1
packages/media/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './application/ports/ImageServicePort';

View File

@@ -0,0 +1,11 @@
{
"name": "@gridpilot/media",
"version": "0.1.0",
"type": "module",
"main": "./index.ts",
"types": "./index.ts",
"exports": {
".": "./index.ts",
"./application/*": "./application/*"
}
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
"declaration": true,
"declarationMap": false
},
"include": ["**/*.ts"]
}

View File

@@ -0,0 +1,16 @@
import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef';
export interface ChampionshipStandingsRowDTO {
participant: ParticipantRef;
position: number;
totalPoints: number;
resultsCounted: number;
resultsDropped: number;
}
export interface ChampionshipStandingsDTO {
seasonId: string;
championshipId: string;
championshipName: string;
rows: ChampionshipStandingsRowDTO[];
}

View File

@@ -8,6 +8,17 @@ export type LeagueDTO = {
sessionDuration?: number;
qualifyingFormat?: 'single-lap' | 'open';
customPoints?: Record<number, number>;
maxDrivers?: number;
};
createdAt: string;
socialLinks?: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
/**
* Number of active driver slots currently used in this league.
* Populated by capacity-aware queries such as GetAllLeaguesWithCapacityQuery.
*/
usedSlots?: number;
};

View File

@@ -0,0 +1,20 @@
export type LeagueDriverSeasonStatsDTO = {
leagueId: string;
driverId: string;
position: number;
driverName: string;
teamId?: string;
teamName?: string;
totalPoints: number;
basePoints: number;
penaltyPoints: number;
bonusPoints: number;
pointsPerRace: number;
racesStarted: number;
racesFinished: number;
dnfs: number;
noShows: number;
avgFinish: number | null;
rating: number | null;
ratingChange: number | null;
};

View File

@@ -14,6 +14,10 @@ export * from './use-cases/GetTeamDetailsQuery';
export * from './use-cases/GetTeamMembersQuery';
export * from './use-cases/GetTeamJoinRequestsQuery';
export * from './use-cases/GetDriverTeamQuery';
export * from './use-cases/GetLeagueStandingsQuery';
export * from './use-cases/GetLeagueDriverSeasonStatsQuery';
export * from './use-cases/GetAllLeaguesWithCapacityQuery';
export * from './use-cases/RecalculateChampionshipStandingsUseCase';
// Re-export domain types for legacy callers (type-only)
export type {
@@ -37,4 +41,9 @@ export type { DriverDTO } from './dto/DriverDTO';
export type { LeagueDTO } from './dto/LeagueDTO';
export type { RaceDTO } from './dto/RaceDTO';
export type { ResultDTO } from './dto/ResultDTO';
export type { StandingDTO } from './dto/StandingDTO';
export type { StandingDTO } from './dto/StandingDTO';
export type { LeagueDriverSeasonStatsDTO } from './dto/LeagueDriverSeasonStatsDTO';
export type {
ChampionshipStandingsDTO,
ChampionshipStandingsRowDTO,
} from './dto/ChampionshipStandingsDTO';

View File

@@ -38,6 +38,15 @@ export class EntityMappers {
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt.toISOString(),
socialLinks: league.socialLinks
? {
discordUrl: league.socialLinks.discordUrl,
youtubeUrl: league.socialLinks.youtubeUrl,
websiteUrl: league.socialLinks.websiteUrl,
}
: undefined,
// usedSlots is populated by capacity-aware queries, so leave undefined here
usedSlots: undefined,
};
}
@@ -49,6 +58,14 @@ export class EntityMappers {
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt.toISOString(),
socialLinks: league.socialLinks
? {
discordUrl: league.socialLinks.discordUrl,
youtubeUrl: league.socialLinks.youtubeUrl,
websiteUrl: league.socialLinks.websiteUrl,
}
: undefined,
usedSlots: undefined,
}));
}

View File

@@ -0,0 +1,58 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { LeagueDTO } from '../dto/LeagueDTO';
export class GetAllLeaguesWithCapacityQuery {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {}
async execute(): Promise<LeagueDTO[]> {
const leagues = await this.leagueRepository.findAll();
const results: LeagueDTO[] = [];
for (const league of leagues) {
const members = await this.leagueMembershipRepository.getLeagueMembers(league.id);
const usedSlots = members.filter(
(m) =>
m.status === 'active' &&
(m.role === 'owner' ||
m.role === 'admin' ||
m.role === 'steward' ||
m.role === 'member'),
).length;
// Ensure we never expose an impossible state like 26/24:
// clamp maxDrivers to at least usedSlots at the application boundary.
const configuredMax = league.settings.maxDrivers ?? usedSlots;
const safeMaxDrivers = Math.max(configuredMax, usedSlots);
const dto: LeagueDTO = {
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
settings: {
...league.settings,
maxDrivers: safeMaxDrivers,
},
createdAt: league.createdAt.toISOString(),
socialLinks: league.socialLinks
? {
discordUrl: league.socialLinks.discordUrl,
youtubeUrl: league.socialLinks.youtubeUrl,
websiteUrl: league.socialLinks.websiteUrl,
}
: undefined,
usedSlots,
};
results.push(dto);
}
return results;
}
}

View File

@@ -0,0 +1,97 @@
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { LeagueDriverSeasonStatsDTO } from '../dto/LeagueDriverSeasonStatsDTO';
export interface DriverRatingPort {
getRating(driverId: string): { rating: number | null; ratingChange: number | null };
}
export interface GetLeagueDriverSeasonStatsQueryParamsDTO {
leagueId: string;
}
export class GetLeagueDriverSeasonStatsQuery {
constructor(
private readonly standingRepository: IStandingRepository,
private readonly resultRepository: IResultRepository,
private readonly penaltyRepository: IPenaltyRepository,
private readonly driverRatingPort: DriverRatingPort,
) {}
async execute(params: GetLeagueDriverSeasonStatsQueryParamsDTO): Promise<LeagueDriverSeasonStatsDTO[]> {
const { leagueId } = params;
const [standings, penaltiesForLeague] = await Promise.all([
this.standingRepository.findByLeagueId(leagueId),
this.penaltyRepository.findByLeagueId(leagueId),
]);
// Group penalties by driver for quick lookup
const penaltiesByDriver = new Map<string, { baseDelta: number; bonusDelta: number }>();
for (const p of penaltiesForLeague) {
const current = penaltiesByDriver.get(p.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
if (p.pointsDelta < 0) {
current.baseDelta += p.pointsDelta;
} else {
current.bonusDelta += p.pointsDelta;
}
penaltiesByDriver.set(p.driverId, current);
}
// Build basic stats per driver from standings
const statsByDriver = new Map<string, LeagueDriverSeasonStatsDTO>();
for (const standing of standings) {
const penalty = penaltiesByDriver.get(standing.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
const totalPenaltyPoints = penalty.baseDelta;
const bonusPoints = penalty.bonusDelta;
const racesCompleted = standing.racesCompleted;
const pointsPerRace = racesCompleted > 0 ? standing.points / racesCompleted : 0;
const ratingInfo = this.driverRatingPort.getRating(standing.driverId);
const dto: LeagueDriverSeasonStatsDTO = {
leagueId,
driverId: standing.driverId,
position: standing.position,
driverName: '',
teamId: undefined,
teamName: undefined,
totalPoints: standing.points + totalPenaltyPoints + bonusPoints,
basePoints: standing.points,
penaltyPoints: Math.abs(totalPenaltyPoints),
bonusPoints,
pointsPerRace,
racesStarted: racesCompleted,
racesFinished: racesCompleted,
dnfs: 0,
noShows: 0,
avgFinish: null,
rating: ratingInfo.rating,
ratingChange: ratingInfo.ratingChange,
};
statsByDriver.set(standing.driverId, dto);
}
// Enhance stats with basic finish-position-based avgFinish from results
for (const [driverId, dto] of statsByDriver.entries()) {
const driverResults = await this.resultRepository.findByDriverIdAndLeagueId(driverId, leagueId);
if (driverResults.length > 0) {
const totalPositions = driverResults.reduce((sum, r) => sum + r.position, 0);
const avgFinish = totalPositions / driverResults.length;
dto.avgFinish = Number.isFinite(avgFinish) ? Number(avgFinish.toFixed(2)) : null;
dto.racesStarted = driverResults.length;
dto.racesFinished = driverResults.length;
}
statsByDriver.set(driverId, dto);
}
// Ensure ordering by position
const result = Array.from(statsByDriver.values()).sort((a, b) => a.position - b.position);
return result;
}
}

View File

@@ -0,0 +1,18 @@
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { StandingDTO } from '../dto/StandingDTO';
import { EntityMappers } from '../mappers/EntityMappers';
export interface GetLeagueStandingsQueryParamsDTO {
leagueId: string;
}
export class GetLeagueStandingsQuery {
constructor(
private readonly standingRepository: IStandingRepository,
) {}
async execute(params: GetLeagueStandingsQueryParamsDTO): Promise<StandingDTO[]> {
const standings = await this.standingRepository.findByLeagueId(params.leagueId);
return EntityMappers.toStandingDTOs(standings);
}
}

View File

@@ -0,0 +1,132 @@
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository';
import type { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRaceRepository';
import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository';
import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository';
import type { IChampionshipStandingRepository } from '@gridpilot/racing/domain/repositories/IChampionshipStandingRepository';
import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig';
import type { SessionType } from '@gridpilot/racing/domain/value-objects/SessionType';
import type { ChampionshipStanding } from '@gridpilot/racing/domain/entities/ChampionshipStanding';
import { EventScoringService } from '@gridpilot/racing/domain/services/EventScoringService';
import { ChampionshipAggregator } from '@gridpilot/racing/domain/services/ChampionshipAggregator';
import type {
ChampionshipStandingsDTO,
ChampionshipStandingsRowDTO,
} from '../dto/ChampionshipStandingsDTO';
export class RecalculateChampionshipStandingsUseCase {
constructor(
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly raceRepository: IRaceRepository,
private readonly resultRepository: IResultRepository,
private readonly penaltyRepository: IPenaltyRepository,
private readonly championshipStandingRepository: IChampionshipStandingRepository,
private readonly eventScoringService: EventScoringService,
private readonly championshipAggregator: ChampionshipAggregator,
) {}
async execute(params: {
seasonId: string;
championshipId: string;
}): Promise<ChampionshipStandingsDTO> {
const { seasonId, championshipId } = params;
const season = await this.seasonRepository.findById(seasonId);
if (!season) {
throw new Error(`Season not found: ${seasonId}`);
}
const leagueScoringConfig =
await this.leagueScoringConfigRepository.findBySeasonId(seasonId);
if (!leagueScoringConfig) {
throw new Error(`League scoring config not found for season: ${seasonId}`);
}
const championship = this.findChampionshipConfig(
leagueScoringConfig.championships,
championshipId,
);
const races = await this.raceRepository.findByLeagueId(season.leagueId);
const eventPointsByEventId: Record<string, ReturnType<EventScoringService['scoreSession']>> =
{};
for (const race of races) {
// Map existing Race.sessionType into scoring SessionType where possible.
const sessionType = this.mapRaceSessionType(race.sessionType);
if (!championship.sessionTypes.includes(sessionType)) {
continue;
}
const results = await this.resultRepository.findByRaceId(race.id);
// For this slice, penalties are league-level and not race-specific,
// so we simply ignore them in the use case to keep behavior minimal.
const penalties = await this.penaltyRepository.findByLeagueId(season.leagueId);
const participantPoints = this.eventScoringService.scoreSession({
seasonId,
championship,
sessionType,
results,
penalties,
});
eventPointsByEventId[race.id] = participantPoints;
}
const standings: ChampionshipStanding[] = this.championshipAggregator.aggregate({
seasonId,
championship,
eventPointsByEventId,
});
await this.championshipStandingRepository.saveAll(standings);
const rows: ChampionshipStandingsRowDTO[] = standings.map((s) => ({
participant: s.participant,
position: s.position,
totalPoints: s.totalPoints,
resultsCounted: s.resultsCounted,
resultsDropped: s.resultsDropped,
}));
const dto: ChampionshipStandingsDTO = {
seasonId,
championshipId: championship.id,
championshipName: championship.name,
rows,
};
return dto;
}
private findChampionshipConfig(
configs: ChampionshipConfig[],
championshipId: string,
): ChampionshipConfig {
const found = configs.find((c) => c.id === championshipId);
if (!found) {
throw new Error(`Championship config not found: ${championshipId}`);
}
return found;
}
private mapRaceSessionType(sessionType: string): SessionType {
if (sessionType === 'race') {
return 'main';
}
if (
sessionType === 'practice' ||
sessionType === 'qualifying' ||
sessionType === 'timeTrial'
) {
return sessionType;
}
return 'main';
}
}

View File

@@ -0,0 +1,41 @@
import type { ParticipantRef } from '../value-objects/ParticipantRef';
export class ChampionshipStanding {
readonly seasonId: string;
readonly championshipId: string;
readonly participant: ParticipantRef;
readonly totalPoints: number;
readonly resultsCounted: number;
readonly resultsDropped: number;
readonly position: number;
constructor(props: {
seasonId: string;
championshipId: string;
participant: ParticipantRef;
totalPoints: number;
resultsCounted: number;
resultsDropped: number;
position: number;
}) {
this.seasonId = props.seasonId;
this.championshipId = props.championshipId;
this.participant = props.participant;
this.totalPoints = props.totalPoints;
this.resultsCounted = props.resultsCounted;
this.resultsDropped = props.resultsDropped;
this.position = props.position;
}
withPosition(position: number): ChampionshipStanding {
return new ChampionshipStanding({
seasonId: this.seasonId,
championshipId: this.championshipId,
participant: this.participant,
totalPoints: this.totalPoints,
resultsCounted: this.resultsCounted,
resultsDropped: this.resultsDropped,
position,
});
}
}

View File

@@ -0,0 +1,24 @@
export class Game {
readonly id: string;
readonly name: string;
private constructor(props: { id: string; name: string }) {
this.id = props.id;
this.name = props.name;
}
static create(props: { id: string; name: string }): Game {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Game ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Game name is required');
}
return new Game({
id: props.id,
name: props.name,
});
}
}

View File

@@ -10,6 +10,17 @@ export interface LeagueSettings {
sessionDuration?: number;
qualifyingFormat?: 'single-lap' | 'open';
customPoints?: Record<number, number>;
/**
* Maximum number of drivers allowed in the league.
* Used for simple capacity display on the website.
*/
maxDrivers?: number;
}
export interface LeagueSocialLinks {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
}
export class League {
@@ -19,6 +30,7 @@ export class League {
readonly ownerId: string;
readonly settings: LeagueSettings;
readonly createdAt: Date;
readonly socialLinks?: LeagueSocialLinks;
private constructor(props: {
id: string;
@@ -27,6 +39,7 @@ export class League {
ownerId: string;
settings: LeagueSettings;
createdAt: Date;
socialLinks?: LeagueSocialLinks;
}) {
this.id = props.id;
this.name = props.name;
@@ -34,6 +47,7 @@ export class League {
this.ownerId = props.ownerId;
this.settings = props.settings;
this.createdAt = props.createdAt;
this.socialLinks = props.socialLinks;
}
/**
@@ -46,6 +60,7 @@ export class League {
ownerId: string;
settings?: Partial<LeagueSettings>;
createdAt?: Date;
socialLinks?: LeagueSocialLinks;
}): League {
this.validate(props);
@@ -53,6 +68,7 @@ export class League {
pointsSystem: 'f1-2024',
sessionDuration: 60,
qualifyingFormat: 'open',
maxDrivers: 32,
};
return new League({
@@ -62,6 +78,7 @@ export class League {
ownerId: props.ownerId,
settings: { ...defaultSettings, ...props.settings },
createdAt: props.createdAt ?? new Date(),
socialLinks: props.socialLinks,
});
}
@@ -102,6 +119,7 @@ export class League {
name: string;
description: string;
settings: LeagueSettings;
socialLinks: LeagueSocialLinks | undefined;
}>): League {
return new League({
id: this.id,
@@ -110,6 +128,7 @@ export class League {
ownerId: this.ownerId,
settings: props.settings ?? this.settings,
createdAt: this.createdAt,
socialLinks: props.socialLinks ?? this.socialLinks,
});
}
}

View File

@@ -0,0 +1,7 @@
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
export interface LeagueScoringConfig {
id: string;
seasonId: string;
championships: ChampionshipConfig[];
}

View File

@@ -0,0 +1,29 @@
/**
* Domain Entity: Penalty
*
* Represents a season-long penalty or bonus applied to a driver
* within a specific league. This is intentionally simple for the
* alpha demo and models points adjustments only.
*/
export type PenaltyType = 'points-deduction' | 'points-bonus';
export interface Penalty {
id: string;
leagueId: string;
driverId: string;
type: PenaltyType;
/**
* Signed integer representing points adjustment:
* - negative for deductions
* - positive for bonuses
*/
pointsDelta: number;
/**
* Optional short reason/label (e.g. "Incident penalty", "Fastest laps bonus").
*/
reason?: string;
/**
* When this penalty was applied.
*/
appliedAt: Date;
}

View File

@@ -0,0 +1,77 @@
export type SeasonStatus = 'planned' | 'active' | 'completed';
export class Season {
readonly id: string;
readonly leagueId: string;
readonly gameId: string;
readonly name: string;
readonly year?: number;
readonly order?: number;
readonly status: SeasonStatus;
readonly startDate?: Date;
readonly endDate?: Date;
private constructor(props: {
id: string;
leagueId: string;
gameId: string;
name: string;
year?: number;
order?: number;
status: SeasonStatus;
startDate?: Date;
endDate?: Date;
}) {
this.id = props.id;
this.leagueId = props.leagueId;
this.gameId = props.gameId;
this.name = props.name;
this.year = props.year;
this.order = props.order;
this.status = props.status;
this.startDate = props.startDate;
this.endDate = props.endDate;
}
static create(props: {
id: string;
leagueId: string;
gameId: string;
name: string;
year?: number;
order?: number;
status?: SeasonStatus;
startDate?: Date;
endDate?: Date;
}): Season {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Season ID is required');
}
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new Error('Season leagueId is required');
}
if (!props.gameId || props.gameId.trim().length === 0) {
throw new Error('Season gameId is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Season name is required');
}
const status: SeasonStatus = props.status ?? 'planned';
return new Season({
id: props.id,
leagueId: props.leagueId,
gameId: props.gameId,
name: props.name,
year: props.year,
order: props.order,
status,
startDate: props.startDate,
endDate: props.endDate,
});
}
}

View File

@@ -0,0 +1,10 @@
import type { ChampionshipStanding } from '../entities/ChampionshipStanding';
export interface IChampionshipStandingRepository {
findBySeasonAndChampionship(
seasonId: string,
championshipId: string,
): Promise<ChampionshipStanding[]>;
saveAll(standings: ChampionshipStanding[]): Promise<void>;
}

View File

@@ -0,0 +1,6 @@
import type { Game } from '../entities/Game';
export interface IGameRepository {
findById(id: string): Promise<Game | null>;
findAll(): Promise<Game[]>;
}

View File

@@ -0,0 +1,5 @@
import type { LeagueScoringConfig } from '../entities/LeagueScoringConfig';
export interface ILeagueScoringConfigRepository {
findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null>;
}

View File

@@ -0,0 +1,25 @@
/**
* Application Port: IPenaltyRepository
*
* Repository interface for season-long penalties and bonuses applied
* to drivers within a league. This is intentionally simple for the
* alpha demo and operates purely on in-memory data.
*/
import type { Penalty } from '../entities/Penalty';
export interface IPenaltyRepository {
/**
* Get all penalties for a given league.
*/
findByLeagueId(leagueId: string): Promise<Penalty[]>;
/**
* Get all penalties for a driver in a specific league.
*/
findByLeagueIdAndDriverId(leagueId: string, driverId: string): Promise<Penalty[]>;
/**
* Get all penalties in the system.
*/
findAll(): Promise<Penalty[]>;
}

View File

@@ -0,0 +1,6 @@
import type { Season } from '../entities/Season';
export interface ISeasonRepository {
findById(id: string): Promise<Season | null>;
findByLeagueId(leagueId: string): Promise<Season[]>;
}

View File

@@ -0,0 +1,71 @@
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
import type { ParticipantRef } from '../value-objects/ParticipantRef';
import { ChampionshipStanding } from '../entities/ChampionshipStanding';
import type { ParticipantEventPoints } from './EventScoringService';
import { DropScoreApplier, type EventPointsEntry } from './DropScoreApplier';
export class ChampionshipAggregator {
constructor(private readonly dropScoreApplier: DropScoreApplier) {}
aggregate(params: {
seasonId: string;
championship: ChampionshipConfig;
eventPointsByEventId: Record<string, ParticipantEventPoints[]>;
}): ChampionshipStanding[] {
const { seasonId, championship, eventPointsByEventId } = params;
const perParticipantEvents = new Map<
string,
{ participant: ParticipantRef; events: EventPointsEntry[] }
>();
for (const [eventId, pointsList] of Object.entries(eventPointsByEventId)) {
for (const entry of pointsList) {
const key = entry.participant.id;
const existing = perParticipantEvents.get(key);
const eventEntry: EventPointsEntry = {
eventId,
points: entry.totalPoints,
};
if (existing) {
existing.events.push(eventEntry);
} else {
perParticipantEvents.set(key, {
participant: entry.participant,
events: [eventEntry],
});
}
}
}
const standings: ChampionshipStanding[] = [];
for (const { participant, events } of perParticipantEvents.values()) {
const dropResult = this.dropScoreApplier.apply(
championship.dropScorePolicy,
events,
);
const totalPoints = dropResult.totalPoints;
const resultsCounted = dropResult.counted.length;
const resultsDropped = dropResult.dropped.length;
standings.push(
new ChampionshipStanding({
seasonId,
championshipId: championship.id,
participant,
totalPoints,
resultsCounted,
resultsDropped,
position: 0,
}),
);
}
standings.sort((a, b) => b.totalPoints - a.totalPoints);
return standings.map((s, index) => s.withPosition(index + 1));
}
}

View File

@@ -0,0 +1,56 @@
import type { DropScorePolicy } from '../value-objects/DropScorePolicy';
export interface EventPointsEntry {
eventId: string;
points: number;
}
export interface DropScoreResult {
counted: EventPointsEntry[];
dropped: EventPointsEntry[];
totalPoints: number;
}
export class DropScoreApplier {
apply(policy: DropScorePolicy, events: EventPointsEntry[]): DropScoreResult {
if (policy.strategy === 'none' || events.length === 0) {
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
return {
counted: [...events],
dropped: [],
totalPoints,
};
}
if (policy.strategy === 'bestNResults') {
const count = policy.count ?? events.length;
if (count >= events.length) {
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
return {
counted: [...events],
dropped: [],
totalPoints,
};
}
const sorted = [...events].sort((a, b) => b.points - a.points);
const counted = sorted.slice(0, count);
const dropped = sorted.slice(count);
const totalPoints = counted.reduce((sum, e) => sum + e.points, 0);
return {
counted,
dropped,
totalPoints,
};
}
// For this slice, treat unsupported strategies as 'none'
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
return {
counted: [...events],
dropped: [],
totalPoints,
};
}
}

View File

@@ -0,0 +1,128 @@
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
import type { SessionType } from '../value-objects/SessionType';
import type { ParticipantRef } from '../value-objects/ParticipantRef';
import type { Result } from '../entities/Result';
import type { Penalty } from '../entities/Penalty';
import type { BonusRule } from '../value-objects/BonusRule';
import type { ChampionshipType } from '../value-objects/ChampionshipType';
import type { PointsTable } from '../value-objects/PointsTable';
export interface ParticipantEventPoints {
participant: ParticipantRef;
basePoints: number;
bonusPoints: number;
penaltyPoints: number;
totalPoints: number;
}
function createDriverParticipant(driverId: string): ParticipantRef {
return {
type: 'driver' as ChampionshipType,
id: driverId,
};
}
export class EventScoringService {
scoreSession(params: {
seasonId: string;
championship: ChampionshipConfig;
sessionType: SessionType;
results: Result[];
penalties: Penalty[];
}): ParticipantEventPoints[] {
const { championship, sessionType, results } = params;
const pointsTable = this.getPointsTableForSession(championship, sessionType);
const bonusRules = this.getBonusRulesForSession(championship, sessionType);
const baseByDriver = new Map<string, number>();
const bonusByDriver = new Map<string, number>();
const penaltyByDriver = new Map<string, number>();
for (const result of results) {
const driverId = result.driverId;
const currentBase = baseByDriver.get(driverId) ?? 0;
const added = pointsTable.getPointsForPosition(result.position);
baseByDriver.set(driverId, currentBase + added);
}
const fastestLapRule = bonusRules.find((r) => r.type === 'fastestLap');
if (fastestLapRule) {
this.applyFastestLapBonus(fastestLapRule, results, bonusByDriver);
}
const penaltyMap = this.aggregatePenalties(params.penalties);
for (const [driverId, value] of penaltyMap.entries()) {
penaltyByDriver.set(driverId, value);
}
const allDriverIds = new Set<string>();
for (const id of baseByDriver.keys()) allDriverIds.add(id);
for (const id of bonusByDriver.keys()) allDriverIds.add(id);
for (const id of penaltyByDriver.keys()) allDriverIds.add(id);
const participants: ParticipantEventPoints[] = [];
for (const driverId of allDriverIds) {
const basePoints = baseByDriver.get(driverId) ?? 0;
const bonusPoints = bonusByDriver.get(driverId) ?? 0;
const penaltyPoints = penaltyByDriver.get(driverId) ?? 0;
const totalPoints = basePoints + bonusPoints - penaltyPoints;
participants.push({
participant: createDriverParticipant(driverId),
basePoints,
bonusPoints,
penaltyPoints,
totalPoints,
});
}
return participants;
}
private getPointsTableForSession(
championship: ChampionshipConfig,
sessionType: SessionType,
): PointsTable {
return championship.pointsTableBySessionType[sessionType];
}
private getBonusRulesForSession(
championship: ChampionshipConfig,
sessionType: SessionType,
): BonusRule[] {
const all = championship.bonusRulesBySessionType ?? {};
return all[sessionType] ?? [];
}
private applyFastestLapBonus(
rule: BonusRule,
results: Result[],
bonusByDriver: Map<string, number>,
): void {
if (results.length === 0) return;
const sortedByLap = [...results].sort((a, b) => a.fastestLap - b.fastestLap);
const best = sortedByLap[0];
const requiresTop = rule.requiresFinishInTopN;
if (typeof requiresTop === 'number') {
if (best.position <= 0 || best.position > requiresTop) {
return;
}
}
const current = bonusByDriver.get(best.driverId) ?? 0;
bonusByDriver.set(best.driverId, current + rule.points);
}
private aggregatePenalties(penalties: Penalty[]): Map<string, number> {
const map = new Map<string, number>();
for (const penalty of penalties) {
const current = map.get(penalty.driverId) ?? 0;
map.set(penalty.driverId, current + penalty.pointsDelta);
}
return map;
}
}

View File

@@ -0,0 +1,8 @@
export type BonusRuleType = 'fastestLap' | 'polePosition' | 'mostPositionsGained';
export interface BonusRule {
id: string;
type: BonusRuleType;
points: number;
requiresFinishInTopN?: number;
}

View File

@@ -0,0 +1,15 @@
import type { ChampionshipType } from './ChampionshipType';
import type { SessionType } from './SessionType';
import { PointsTable } from './PointsTable';
import type { BonusRule } from './BonusRule';
import type { DropScorePolicy } from './DropScorePolicy';
export interface ChampionshipConfig {
id: string;
name: string;
type: ChampionshipType;
sessionTypes: SessionType[];
pointsTableBySessionType: Record<SessionType, PointsTable>;
bonusRulesBySessionType?: Record<SessionType, BonusRule[]>;
dropScorePolicy: DropScorePolicy;
}

View File

@@ -0,0 +1 @@
export type ChampionshipType = 'driver' | 'team' | 'nations' | 'trophy';

View File

@@ -0,0 +1,13 @@
export type DropScoreStrategy = 'none' | 'bestNResults' | 'dropWorstN';
export interface DropScorePolicy {
strategy: DropScoreStrategy;
/**
* For 'bestNResults': number of best-scoring events to count.
*/
count?: number;
/**
* For 'dropWorstN': number of worst-scoring events to drop.
*/
dropCount?: number;
}

View File

@@ -0,0 +1,6 @@
import type { ChampionshipType } from './ChampionshipType';
export interface ParticipantRef {
type: ChampionshipType;
id: string;
}

View File

@@ -0,0 +1,21 @@
export class PointsTable {
private readonly pointsByPosition: Map<number, number>;
constructor(pointsByPosition: Record<number, number> | Map<number, number>) {
if (pointsByPosition instanceof Map) {
this.pointsByPosition = new Map(pointsByPosition);
} else {
this.pointsByPosition = new Map(
Object.entries(pointsByPosition).map(([key, value]) => [Number(key), value]),
);
}
}
getPointsForPosition(position: number): number {
if (!Number.isInteger(position) || position < 1) {
return 0;
}
const value = this.pointsByPosition.get(position);
return typeof value === 'number' ? value : 0;
}
}

View File

@@ -0,0 +1,9 @@
export type SessionType =
| 'practice'
| 'qualifying'
| 'q1'
| 'q2'
| 'q3'
| 'sprint'
| 'main'
| 'timeTrial';

View File

@@ -0,0 +1,100 @@
/**
* Infrastructure Adapter: InMemoryLeagueMembershipRepository
*
* In-memory implementation of ILeagueMembershipRepository.
* Stores memberships and join requests in maps keyed by league.
*/
import type {
LeagueMembership,
JoinRequest,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
export class InMemoryLeagueMembershipRepository implements ILeagueMembershipRepository {
private membershipsByLeague: Map<string, LeagueMembership[]>;
private joinRequestsByLeague: Map<string, JoinRequest[]>;
constructor(seedMemberships?: LeagueMembership[], seedJoinRequests?: JoinRequest[]) {
this.membershipsByLeague = new Map();
this.joinRequestsByLeague = new Map();
if (seedMemberships) {
seedMemberships.forEach((membership) => {
const list = this.membershipsByLeague.get(membership.leagueId) ?? [];
list.push(membership);
this.membershipsByLeague.set(membership.leagueId, list);
});
}
if (seedJoinRequests) {
seedJoinRequests.forEach((request) => {
const list = this.joinRequestsByLeague.get(request.leagueId) ?? [];
list.push(request);
this.joinRequestsByLeague.set(request.leagueId, list);
});
}
}
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
const list = this.membershipsByLeague.get(leagueId);
if (!list) return null;
return list.find((m) => m.driverId === driverId) ?? null;
}
async getLeagueMembers(leagueId: string): Promise<LeagueMembership[]> {
return [...(this.membershipsByLeague.get(leagueId) ?? [])];
}
async getJoinRequests(leagueId: string): Promise<JoinRequest[]> {
return [...(this.joinRequestsByLeague.get(leagueId) ?? [])];
}
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
const list = this.membershipsByLeague.get(membership.leagueId) ?? [];
const existingIndex = list.findIndex(
(m) => m.leagueId === membership.leagueId && m.driverId === membership.driverId,
);
if (existingIndex >= 0) {
list[existingIndex] = membership;
} else {
list.push(membership);
}
this.membershipsByLeague.set(membership.leagueId, list);
return membership;
}
async removeMembership(leagueId: string, driverId: string): Promise<void> {
const list = this.membershipsByLeague.get(leagueId);
if (!list) return;
const next = list.filter((m) => m.driverId !== driverId);
this.membershipsByLeague.set(leagueId, next);
}
async saveJoinRequest(request: JoinRequest): Promise<JoinRequest> {
const list = this.joinRequestsByLeague.get(request.leagueId) ?? [];
const existingIndex = list.findIndex((r) => r.id === request.id);
if (existingIndex >= 0) {
list[existingIndex] = request;
} else {
list.push(request);
}
this.joinRequestsByLeague.set(request.leagueId, list);
return request;
}
async removeJoinRequest(requestId: string): Promise<void> {
for (const [leagueId, requests] of this.joinRequestsByLeague.entries()) {
const next = requests.filter((r) => r.id !== requestId);
if (next.length !== requests.length) {
this.joinRequestsByLeague.set(leagueId, next);
break;
}
}
}
}

View File

@@ -0,0 +1,85 @@
/**
* Infrastructure Adapter: InMemoryPenaltyRepository
*
* Simple in-memory implementation of IPenaltyRepository seeded with
* a handful of demo penalties and bonuses for leagues/drivers.
*/
import type { Penalty } from '@gridpilot/racing/domain/entities/Penalty';
import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository';
export class InMemoryPenaltyRepository implements IPenaltyRepository {
private readonly penalties: Penalty[];
constructor(seedPenalties?: Penalty[]) {
this.penalties = seedPenalties ? [...seedPenalties] : InMemoryPenaltyRepository.createDefaultSeed();
}
async findByLeagueId(leagueId: string): Promise<Penalty[]> {
return this.penalties.filter((p) => p.leagueId === leagueId);
}
async findByLeagueIdAndDriverId(leagueId: string, driverId: string): Promise<Penalty[]> {
return this.penalties.filter((p) => p.leagueId === leagueId && p.driverId === driverId);
}
async findAll(): Promise<Penalty[]> {
return [...this.penalties];
}
/**
* Default demo seed with a mix of deductions and bonuses
* across a couple of leagues and drivers.
*/
private static createDefaultSeed(): Penalty[] {
const now = new Date();
const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
return [
{
id: 'pen-league-1-driver-1-main',
leagueId: 'league-1',
driverId: 'driver-1',
type: 'points-deduction',
pointsDelta: -3,
reason: 'Incident points penalty',
appliedAt: daysAgo(7),
},
{
id: 'pen-league-1-driver-2-bonus',
leagueId: 'league-1',
driverId: 'driver-2',
type: 'points-bonus',
pointsDelta: 2,
reason: 'Fastest laps bonus',
appliedAt: daysAgo(5),
},
{
id: 'pen-league-1-driver-3-bonus',
leagueId: 'league-1',
driverId: 'driver-3',
type: 'points-bonus',
pointsDelta: 1,
reason: 'Pole position bonus',
appliedAt: daysAgo(3),
},
{
id: 'pen-league-2-driver-4-main',
leagueId: 'league-2',
driverId: 'driver-4',
type: 'points-deduction',
pointsDelta: -5,
reason: 'Post-race steward decision',
appliedAt: daysAgo(10),
},
{
id: 'pen-league-2-driver-5-bonus',
leagueId: 'league-2',
driverId: 'driver-5',
type: 'points-bonus',
pointsDelta: 3,
reason: 'Clean race awards',
appliedAt: daysAgo(2),
},
];
}
}

View File

@@ -0,0 +1,115 @@
/**
* Infrastructure Adapter: InMemoryRaceRegistrationRepository
*
* In-memory implementation of IRaceRegistrationRepository.
* Stores race registrations in Maps keyed by raceId and driverId.
*/
import type { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
export class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepository {
private registrationsByRace: Map<string, Set<string>>;
private registrationsByDriver: Map<string, Set<string>>;
constructor(seedRegistrations?: RaceRegistration[]) {
this.registrationsByRace = new Map();
this.registrationsByDriver = new Map();
if (seedRegistrations) {
seedRegistrations.forEach((registration) => {
this.addToIndexes(registration.raceId, registration.driverId, registration.registeredAt);
});
}
}
private addToIndexes(raceId: string, driverId: string, _registeredAt: Date): void {
let raceSet = this.registrationsByRace.get(raceId);
if (!raceSet) {
raceSet = new Set();
this.registrationsByRace.set(raceId, raceSet);
}
raceSet.add(driverId);
let driverSet = this.registrationsByDriver.get(driverId);
if (!driverSet) {
driverSet = new Set();
this.registrationsByDriver.set(driverId, driverSet);
}
driverSet.add(raceId);
}
private removeFromIndexes(raceId: string, driverId: string): void {
const raceSet = this.registrationsByRace.get(raceId);
if (raceSet) {
raceSet.delete(driverId);
if (raceSet.size === 0) {
this.registrationsByRace.delete(raceId);
}
}
const driverSet = this.registrationsByDriver.get(driverId);
if (driverSet) {
driverSet.delete(raceId);
if (driverSet.size === 0) {
this.registrationsByDriver.delete(driverId);
}
}
}
async isRegistered(raceId: string, driverId: string): Promise<boolean> {
const raceSet = this.registrationsByRace.get(raceId);
if (!raceSet) return false;
return raceSet.has(driverId);
}
async getRegisteredDrivers(raceId: string): Promise<string[]> {
const raceSet = this.registrationsByRace.get(raceId);
if (!raceSet) return [];
return Array.from(raceSet.values());
}
async getRegistrationCount(raceId: string): Promise<number> {
const raceSet = this.registrationsByRace.get(raceId);
return raceSet ? raceSet.size : 0;
}
async register(registration: RaceRegistration): Promise<void> {
const alreadyRegistered = await this.isRegistered(registration.raceId, registration.driverId);
if (alreadyRegistered) {
throw new Error('Already registered for this race');
}
this.addToIndexes(registration.raceId, registration.driverId, registration.registeredAt);
}
async withdraw(raceId: string, driverId: string): Promise<void> {
const alreadyRegistered = await this.isRegistered(raceId, driverId);
if (!alreadyRegistered) {
throw new Error('Not registered for this race');
}
this.removeFromIndexes(raceId, driverId);
}
async getDriverRegistrations(driverId: string): Promise<string[]> {
const driverSet = this.registrationsByDriver.get(driverId);
if (!driverSet) return [];
return Array.from(driverSet.values());
}
async clearRaceRegistrations(raceId: string): Promise<void> {
const raceSet = this.registrationsByRace.get(raceId);
if (!raceSet) return;
for (const driverId of raceSet.values()) {
const driverSet = this.registrationsByDriver.get(driverId);
if (driverSet) {
driverSet.delete(raceId);
if (driverSet.size === 0) {
this.registrationsByDriver.delete(driverId);
}
}
}
this.registrationsByRace.delete(raceId);
}
}

View File

@@ -0,0 +1,229 @@
import { Game } from '@gridpilot/racing/domain/entities/Game';
import { Season } from '@gridpilot/racing/domain/entities/Season';
import type { LeagueScoringConfig } from '@gridpilot/racing/domain/entities/LeagueScoringConfig';
import { PointsTable } from '@gridpilot/racing/domain/value-objects/PointsTable';
import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig';
import type { SessionType } from '@gridpilot/racing/domain/value-objects/SessionType';
import type { BonusRule } from '@gridpilot/racing/domain/value-objects/BonusRule';
import type { DropScorePolicy } from '@gridpilot/racing/domain/value-objects/DropScorePolicy';
import type { IGameRepository } from '@gridpilot/racing/domain/repositories/IGameRepository';
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository';
import type { IChampionshipStandingRepository } from '@gridpilot/racing/domain/repositories/IChampionshipStandingRepository';
import { ChampionshipStanding } from '@gridpilot/racing/domain/entities/ChampionshipStanding';
import type { ChampionshipType } from '@gridpilot/racing/domain/value-objects/ChampionshipType';
import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef';
export class InMemoryGameRepository implements IGameRepository {
private games: Game[];
constructor(seedData?: Game[]) {
this.games = seedData ? [...seedData] : [];
}
async findById(id: string): Promise<Game | null> {
return this.games.find((g) => g.id === id) ?? null;
}
async findAll(): Promise<Game[]> {
return [...this.games];
}
seed(game: Game): void {
this.games.push(game);
}
}
export class InMemorySeasonRepository implements ISeasonRepository {
private seasons: Season[];
constructor(seedData?: Season[]) {
this.seasons = seedData ? [...seedData] : [];
}
async findById(id: string): Promise<Season | null> {
return this.seasons.find((s) => s.id === id) ?? null;
}
async findByLeagueId(leagueId: string): Promise<Season[]> {
return this.seasons.filter((s) => s.leagueId === leagueId);
}
seed(season: Season): void {
this.seasons.push(season);
}
}
export class InMemoryLeagueScoringConfigRepository
implements ILeagueScoringConfigRepository
{
private configs: LeagueScoringConfig[];
constructor(seedData?: LeagueScoringConfig[]) {
this.configs = seedData ? [...seedData] : [];
}
async findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null> {
return this.configs.find((c) => c.seasonId === seasonId) ?? null;
}
seed(config: LeagueScoringConfig): void {
this.configs.push(config);
}
}
export class InMemoryChampionshipStandingRepository
implements IChampionshipStandingRepository
{
private standings: ChampionshipStanding[] = [];
async findBySeasonAndChampionship(
seasonId: string,
championshipId: string,
): Promise<ChampionshipStanding[]> {
return this.standings.filter(
(s) => s.seasonId === seasonId && s.championshipId === championshipId,
);
}
async saveAll(standings: ChampionshipStanding[]): Promise<void> {
this.standings = standings;
}
seed(standing: ChampionshipStanding): void {
this.standings.push(standing);
}
getAll(): ChampionshipStanding[] {
return [...this.standings];
}
}
export function createF1DemoScoringSetup(params: {
leagueId: string;
seasonId?: string;
}): {
gameRepo: InMemoryGameRepository;
seasonRepo: InMemorySeasonRepository;
scoringConfigRepo: InMemoryLeagueScoringConfigRepository;
championshipStandingRepo: InMemoryChampionshipStandingRepository;
seasonId: string;
championshipId: string;
} {
const { leagueId } = params;
const seasonId = params.seasonId ?? 'season-f1-demo';
const championshipId = 'driver-champ';
const game = Game.create({ id: 'iracing', name: 'iRacing' });
const season = Season.create({
id: seasonId,
leagueId,
gameId: game.id,
name: 'F1-Style Demo Season',
year: 2025,
order: 1,
status: 'active',
startDate: new Date('2025-01-01'),
endDate: new Date('2025-12-31'),
});
const mainPoints = new PointsTable({
1: 25,
2: 18,
3: 15,
4: 12,
5: 10,
6: 8,
7: 6,
8: 4,
9: 2,
10: 1,
});
const sprintPoints = new PointsTable({
1: 8,
2: 7,
3: 6,
4: 5,
5: 4,
6: 3,
7: 2,
8: 1,
});
const fastestLapBonus: BonusRule = {
id: 'fastest-lap-main',
type: 'fastestLap',
points: 1,
requiresFinishInTopN: 10,
};
const sessionTypes: SessionType[] = ['sprint', 'main'];
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
sprint: sprintPoints,
main: mainPoints,
practice: new PointsTable({}),
qualifying: new PointsTable({}),
q1: new PointsTable({}),
q2: new PointsTable({}),
q3: new PointsTable({}),
timeTrial: new PointsTable({}),
};
const bonusRulesBySessionType: Record<SessionType, BonusRule[]> = {
sprint: [],
main: [fastestLapBonus],
practice: [],
qualifying: [],
q1: [],
q2: [],
q3: [],
timeTrial: [],
};
const dropScorePolicy: DropScorePolicy = {
strategy: 'bestNResults',
count: 6,
};
const championship: ChampionshipConfig = {
id: championshipId,
name: 'Driver Championship',
type: 'driver' as ChampionshipType,
sessionTypes,
pointsTableBySessionType,
bonusRulesBySessionType,
dropScorePolicy,
};
const leagueScoringConfig: LeagueScoringConfig = {
id: 'lsc-f1-demo',
seasonId: season.id,
championships: [championship],
};
const gameRepo = new InMemoryGameRepository([game]);
const seasonRepo = new InMemorySeasonRepository([season]);
const scoringConfigRepo = new InMemoryLeagueScoringConfigRepository([
leagueScoringConfig,
]);
const championshipStandingRepo = new InMemoryChampionshipStandingRepository();
return {
gameRepo,
seasonRepo,
scoringConfigRepo,
championshipStandingRepo,
seasonId: season.id,
championshipId,
};
}
export function createParticipantRef(driverId: string): ParticipantRef {
return {
type: 'driver' as ChampionshipType,
id: driverId,
};
}

View File

@@ -0,0 +1,135 @@
/**
* Infrastructure Adapter: InMemoryTeamMembershipRepository
*
* In-memory implementation of ITeamMembershipRepository.
* Stores memberships and join requests in Map structures.
*/
import type {
TeamMembership,
TeamJoinRequest,
} from '@gridpilot/racing/domain/entities/Team';
import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository';
export class InMemoryTeamMembershipRepository implements ITeamMembershipRepository {
private membershipsByTeam: Map<string, TeamMembership[]>;
private joinRequestsByTeam: Map<string, TeamJoinRequest[]>;
constructor(seedMemberships?: TeamMembership[], seedJoinRequests?: TeamJoinRequest[]) {
this.membershipsByTeam = new Map();
this.joinRequestsByTeam = new Map();
if (seedMemberships) {
seedMemberships.forEach((membership) => {
const list = this.membershipsByTeam.get(membership.teamId) ?? [];
list.push(membership);
this.membershipsByTeam.set(membership.teamId, list);
});
}
if (seedJoinRequests) {
seedJoinRequests.forEach((request) => {
const list = this.joinRequestsByTeam.get(request.teamId) ?? [];
list.push(request);
this.joinRequestsByTeam.set(request.teamId, list);
});
}
}
private getMembershipList(teamId: string): TeamMembership[] {
let list = this.membershipsByTeam.get(teamId);
if (!list) {
list = [];
this.membershipsByTeam.set(teamId, list);
}
return list;
}
private getJoinRequestList(teamId: string): TeamJoinRequest[] {
let list = this.joinRequestsByTeam.get(teamId);
if (!list) {
list = [];
this.joinRequestsByTeam.set(teamId, list);
}
return list;
}
async getMembership(teamId: string, driverId: string): Promise<TeamMembership | null> {
const list = this.membershipsByTeam.get(teamId);
if (!list) return null;
return list.find((m) => m.driverId === driverId) ?? null;
}
async getActiveMembershipForDriver(driverId: string): Promise<TeamMembership | null> {
for (const list of this.membershipsByTeam.values()) {
const membership = list.find(
(m) => m.driverId === driverId && m.status === 'active',
);
if (membership) {
return membership;
}
}
return null;
}
async getTeamMembers(teamId: string): Promise<TeamMembership[]> {
return [...(this.membershipsByTeam.get(teamId) ?? [])];
}
async saveMembership(membership: TeamMembership): Promise<TeamMembership> {
const list = this.getMembershipList(membership.teamId);
const existingIndex = list.findIndex(
(m) => m.teamId === membership.teamId && m.driverId === membership.driverId,
);
if (existingIndex >= 0) {
list[existingIndex] = membership;
} else {
list.push(membership);
}
this.membershipsByTeam.set(membership.teamId, list);
return membership;
}
async removeMembership(teamId: string, driverId: string): Promise<void> {
const list = this.membershipsByTeam.get(teamId);
if (!list) {
return;
}
const index = list.findIndex((m) => m.driverId === driverId);
if (index >= 0) {
list.splice(index, 1);
this.membershipsByTeam.set(teamId, list);
}
}
async getJoinRequests(teamId: string): Promise<TeamJoinRequest[]> {
return [...(this.joinRequestsByTeam.get(teamId) ?? [])];
}
async saveJoinRequest(request: TeamJoinRequest): Promise<TeamJoinRequest> {
const list = this.getJoinRequestList(request.teamId);
const existingIndex = list.findIndex((r) => r.id === request.id);
if (existingIndex >= 0) {
list[existingIndex] = request;
} else {
list.push(request);
}
this.joinRequestsByTeam.set(request.teamId, list);
return request;
}
async removeJoinRequest(requestId: string): Promise<void> {
for (const [teamId, list] of this.joinRequestsByTeam.entries()) {
const index = list.findIndex((r) => r.id === requestId);
if (index >= 0) {
list.splice(index, 1);
this.joinRequestsByTeam.set(teamId, list);
return;
}
}
}
}

View File

@@ -0,0 +1,67 @@
/**
* Infrastructure Adapter: InMemoryTeamRepository
*
* In-memory implementation of ITeamRepository.
* Stores data in a Map structure.
*/
import type { Team } from '@gridpilot/racing/domain/entities/Team';
import type { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository';
export class InMemoryTeamRepository implements ITeamRepository {
private teams: Map<string, Team>;
constructor(seedData?: Team[]) {
this.teams = new Map();
if (seedData) {
seedData.forEach((team) => {
this.teams.set(team.id, team);
});
}
}
async findById(id: string): Promise<Team | null> {
return this.teams.get(id) ?? null;
}
async findAll(): Promise<Team[]> {
return Array.from(this.teams.values());
}
async findByLeagueId(leagueId: string): Promise<Team[]> {
return Array.from(this.teams.values()).filter((team) =>
team.leagues.includes(leagueId),
);
}
async create(team: Team): Promise<Team> {
if (await this.exists(team.id)) {
throw new Error(`Team with ID ${team.id} already exists`);
}
this.teams.set(team.id, team);
return team;
}
async update(team: Team): Promise<Team> {
if (!(await this.exists(team.id))) {
throw new Error(`Team with ID ${team.id} not found`);
}
this.teams.set(team.id, team);
return team;
}
async delete(id: string): Promise<void> {
if (!(await this.exists(id))) {
throw new Error(`Team with ID ${id} not found`);
}
this.teams.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.teams.has(id);
}
}

View File

@@ -1,3 +1,5 @@
import { faker } from '../faker/faker';
const DRIVER_AVATARS = [
'/images/avatars/avatar-1.svg',
'/images/avatars/avatar-2.svg',
@@ -44,4 +46,20 @@ export function getLeagueBanner(leagueId: string): string {
return LEAGUE_BANNERS[index];
}
export interface LeagueCoverImage {
url: string;
alt: string;
}
export function getLeagueCoverImage(leagueId: string): LeagueCoverImage {
const seed = hashString(leagueId);
faker.seed(seed);
const alt = faker.lorem.words(3);
const url = `https://picsum.photos/seed/${seed}/1200/280?blur=2`;
return { url, alt };
}
export { DRIVER_AVATARS, TEAM_LOGOS, LEAGUE_BANNERS };

View File

@@ -103,12 +103,32 @@ function createLeagues(ownerIds: string[]): League[] {
const name = leagueNames[i] ?? faker.company.name();
const ownerId = pickOne(ownerIds);
const maxDriversOptions = [24, 32, 48, 64];
const settings = {
pointsSystem: faker.helpers.arrayElement(['f1-2024', 'indycar']),
sessionDuration: faker.helpers.arrayElement([45, 60, 90, 120]),
qualifyingFormat: faker.helpers.arrayElement(['open', 'single-lap']),
maxDrivers: faker.helpers.arrayElement(maxDriversOptions),
};
const socialLinks =
i === 0
? {
discordUrl: 'https://discord.gg/gridpilot-demo',
youtubeUrl: 'https://youtube.com/@gridpilot-demo',
websiteUrl: 'https://gridpilot-demo.example.com',
}
: i === 1
? {
discordUrl: 'https://discord.gg/gridpilot-endurance',
youtubeUrl: 'https://youtube.com/@gridpilot-endurance',
}
: i === 2
? {
websiteUrl: 'https://virtual-touring.example.com',
}
: undefined;
leagues.push(
League.create({
id,
@@ -117,6 +137,7 @@ function createLeagues(ownerIds: string[]): League[] {
ownerId,
settings,
createdAt: faker.date.past(),
socialLinks,
}),
);
}

View File

@@ -0,0 +1,426 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { RecalculateChampionshipStandingsUseCase } from '@gridpilot/racing/application/use-cases/RecalculateChampionshipStandingsUseCase';
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository';
import type { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRaceRepository';
import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository';
import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository';
import type { IChampionshipStandingRepository } from '@gridpilot/racing/domain/repositories/IChampionshipStandingRepository';
import type { Season } from '@gridpilot/racing/domain/entities/Season';
import type { LeagueScoringConfig } from '@gridpilot/racing/domain/entities/LeagueScoringConfig';
import type { Race } from '@gridpilot/racing/domain/entities/Race';
import type { Result } from '@gridpilot/racing/domain/entities/Result';
import type { Penalty } from '@gridpilot/racing/domain/entities/Penalty';
import type { ChampionshipStanding } from '@gridpilot/racing/domain/entities/ChampionshipStanding';
import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig';
import { EventScoringService } from '@gridpilot/racing/domain/services/EventScoringService';
import { DropScoreApplier } from '@gridpilot/racing/domain/services/DropScoreApplier';
import { ChampionshipAggregator } from '@gridpilot/racing/domain/services/ChampionshipAggregator';
import { PointsTable } from '@gridpilot/racing/domain/value-objects/PointsTable';
import type { SessionType } from '@gridpilot/racing/domain/value-objects/SessionType';
import type { BonusRule } from '@gridpilot/racing/domain/value-objects/BonusRule';
import type { DropScorePolicy } from '@gridpilot/racing/domain/value-objects/DropScorePolicy';
class InMemorySeasonRepository implements ISeasonRepository {
private seasons: Season[] = [];
async findById(id: string): Promise<Season | null> {
return this.seasons.find((s) => s.id === id) || null;
}
async findByLeagueId(leagueId: string): Promise<Season[]> {
return this.seasons.filter((s) => s.leagueId === leagueId);
}
seedSeason(season: Season): void {
this.seasons.push(season);
}
}
class InMemoryLeagueScoringConfigRepository implements ILeagueScoringConfigRepository {
private configs: LeagueScoringConfig[] = [];
async findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null> {
return this.configs.find((c) => c.seasonId === seasonId) || null;
}
seedConfig(config: LeagueScoringConfig): void {
this.configs.push(config);
}
}
class InMemoryRaceRepository implements IRaceRepository {
private races: Race[] = [];
async findById(id: string): Promise<Race | null> {
return this.races.find((r) => r.id === id) || null;
}
async findAll(): Promise<Race[]> {
return [...this.races];
}
async findByLeagueId(leagueId: string): Promise<Race[]> {
return this.races.filter((r) => r.leagueId === leagueId);
}
async findUpcomingByLeagueId(): Promise<Race[]> {
return [];
}
async findCompletedByLeagueId(): Promise<Race[]> {
return [];
}
async findByStatus(): Promise<Race[]> {
return [];
}
async findByDateRange(): Promise<Race[]> {
return [];
}
async create(race: Race): Promise<Race> {
this.races.push(race);
return race;
}
async update(race: Race): Promise<Race> {
const index = this.races.findIndex((r) => r.id === race.id);
if (index >= 0) {
this.races[index] = race;
} else {
this.races.push(race);
}
return race;
}
async delete(id: string): Promise<void> {
this.races = this.races.filter((r) => r.id !== id);
}
async exists(id: string): Promise<boolean> {
return this.races.some((r) => r.id === id);
}
seedRace(race: Race): void {
this.races.push(race);
}
}
class InMemoryResultRepository implements IResultRepository {
private results: Result[] = [];
async findByRaceId(raceId: string): Promise<Result[]> {
return this.results.filter((r) => r.raceId === raceId);
}
seedResult(result: Result): void {
this.results.push(result);
}
}
class InMemoryPenaltyRepository implements IPenaltyRepository {
private penalties: Penalty[] = [];
async findByLeagueId(leagueId: string): Promise<Penalty[]> {
return this.penalties.filter((p) => p.leagueId === leagueId);
}
async findByLeagueIdAndDriverId(
leagueId: string,
driverId: string,
): Promise<Penalty[]> {
return this.penalties.filter(
(p) => p.leagueId === leagueId && p.driverId === driverId,
);
}
async findAll(): Promise<Penalty[]> {
return [...this.penalties];
}
seedPenalty(penalty: Penalty): void {
this.penalties.push(penalty);
}
}
class InMemoryChampionshipStandingRepository implements IChampionshipStandingRepository {
private standings: ChampionshipStanding[] = [];
async findBySeasonAndChampionship(
seasonId: string,
championshipId: string,
): Promise<ChampionshipStanding[]> {
return this.standings.filter(
(s) => s.seasonId === seasonId && s.championshipId === championshipId,
);
}
async saveAll(standings: ChampionshipStanding[]): Promise<void> {
this.standings = standings;
}
getAll(): ChampionshipStanding[] {
return [...this.standings];
}
}
function makePointsTable(points: number[]): PointsTable {
const byPos: Record<number, number> = {};
points.forEach((p, idx) => {
byPos[idx + 1] = p;
});
return new PointsTable(byPos);
}
function makeChampionshipConfig(): ChampionshipConfig {
const mainPoints = makePointsTable([25, 18, 15, 12, 10, 8, 6, 4, 2, 1]);
const sprintPoints = makePointsTable([8, 7, 6, 5, 4, 3, 2, 1]);
const fastestLapBonus: BonusRule = {
id: 'fastest-lap-main',
type: 'fastestLap',
points: 1,
requiresFinishInTopN: 10,
};
const sessionTypes: SessionType[] = ['sprint', 'main'];
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
sprint: sprintPoints,
main: mainPoints,
practice: new PointsTable({}),
qualifying: new PointsTable({}),
q1: new PointsTable({}),
q2: new PointsTable({}),
q3: new PointsTable({}),
timeTrial: new PointsTable({}),
};
const bonusRulesBySessionType: Record<SessionType, BonusRule[]> = {
sprint: [],
main: [fastestLapBonus],
practice: [],
qualifying: [],
q1: [],
q2: [],
q3: [],
timeTrial: [],
};
const dropScorePolicy: DropScorePolicy = {
strategy: 'bestNResults',
count: 6,
};
return {
id: 'driver-champ',
name: 'Driver Championship',
type: 'driver',
sessionTypes,
pointsTableBySessionType,
bonusRulesBySessionType,
dropScorePolicy,
};
}
describe('RecalculateChampionshipStandingsUseCase', () => {
const leagueId = 'league-1';
const seasonId = 'season-1';
const championshipId = 'driver-champ';
let seasonRepository: InMemorySeasonRepository;
let leagueScoringConfigRepository: InMemoryLeagueScoringConfigRepository;
let raceRepository: InMemoryRaceRepository;
let resultRepository: InMemoryResultRepository;
let penaltyRepository: InMemoryPenaltyRepository;
let championshipStandingRepository: InMemoryChampionshipStandingRepository;
let useCase: RecalculateChampionshipStandingsUseCase;
beforeEach(() => {
seasonRepository = new InMemorySeasonRepository();
leagueScoringConfigRepository = new InMemoryLeagueScoringConfigRepository();
raceRepository = new InMemoryRaceRepository();
resultRepository = new InMemoryResultRepository();
penaltyRepository = new InMemoryPenaltyRepository();
championshipStandingRepository = new InMemoryChampionshipStandingRepository();
const eventScoringService = new EventScoringService();
const dropScoreApplier = new DropScoreApplier();
const championshipAggregator = new ChampionshipAggregator(dropScoreApplier);
useCase = new RecalculateChampionshipStandingsUseCase(
seasonRepository,
leagueScoringConfigRepository,
raceRepository,
resultRepository,
penaltyRepository,
championshipStandingRepository,
eventScoringService,
championshipAggregator,
);
const season: Season = {
id: seasonId,
leagueId,
gameId: 'iracing',
name: 'Demo Season',
status: 'active',
year: 2025,
order: 1,
startDate: new Date('2025-01-01'),
endDate: new Date('2025-12-31'),
};
seasonRepository.seedSeason(season);
const championship = makeChampionshipConfig();
const leagueScoringConfig: LeagueScoringConfig = {
id: 'lsc-1',
seasonId,
championships: [championship],
};
leagueScoringConfigRepository.seedConfig(leagueScoringConfig);
const races: Race[] = [
{
id: 'race-1-sprint',
leagueId,
scheduledAt: new Date('2025-02-01'),
track: 'Track 1',
car: 'Car A',
sessionType: 'race',
status: 'completed',
},
{
id: 'race-1-main',
leagueId,
scheduledAt: new Date('2025-02-01'),
track: 'Track 1',
car: 'Car A',
sessionType: 'race',
status: 'completed',
},
{
id: 'race-2-sprint',
leagueId,
scheduledAt: new Date('2025-03-01'),
track: 'Track 2',
car: 'Car A',
sessionType: 'race',
status: 'completed',
},
{
id: 'race-2-main',
leagueId,
scheduledAt: new Date('2025-03-01'),
track: 'Track 2',
car: 'Car A',
sessionType: 'race',
status: 'completed',
},
{
id: 'race-3-sprint',
leagueId,
scheduledAt: new Date('2025-04-01'),
track: 'Track 3',
car: 'Car A',
sessionType: 'race',
status: 'completed',
},
{
id: 'race-3-main',
leagueId,
scheduledAt: new Date('2025-04-01'),
track: 'Track 3',
car: 'Car A',
sessionType: 'race',
status: 'completed',
},
];
races.forEach((race) => raceRepository.seedRace(race));
const drivers = ['driver-1', 'driver-2', 'driver-3'];
const resultsData: Array<{
raceId: string;
finishingOrder: string[];
fastestLapDriverId: string;
}> = [
{
raceId: 'race-1-sprint',
finishingOrder: ['driver-1', 'driver-2', 'driver-3'],
fastestLapDriverId: 'driver-2',
},
{
raceId: 'race-1-main',
finishingOrder: ['driver-2', 'driver-1', 'driver-3'],
fastestLapDriverId: 'driver-1',
},
{
raceId: 'race-2-sprint',
finishingOrder: ['driver-1', 'driver-3', 'driver-2'],
fastestLapDriverId: 'driver-1',
},
{
raceId: 'race-2-main',
finishingOrder: ['driver-1', 'driver-2', 'driver-3'],
fastestLapDriverId: 'driver-1',
},
{
raceId: 'race-3-sprint',
finishingOrder: ['driver-2', 'driver-1', 'driver-3'],
fastestLapDriverId: 'driver-2',
},
{
raceId: 'race-3-main',
finishingOrder: ['driver-3', 'driver-1', 'driver-2'],
fastestLapDriverId: 'driver-3',
},
];
let resultIdCounter = 1;
for (const raceData of resultsData) {
raceData.finishingOrder.forEach((driverId, index) => {
const result: Result = {
id: `result-${resultIdCounter++}`,
raceId: raceData.raceId,
driverId,
position: index + 1,
fastestLap: driverId === raceData.fastestLapDriverId ? 90000 : 91000 + index * 100,
incidents: 0,
startPosition: index + 1,
};
resultRepository.seedResult(result);
});
}
});
it('recalculates standings for a driver championship with sprint and main races', async () => {
const dto = await useCase.execute({
seasonId,
championshipId,
});
expect(dto.seasonId).toBe(seasonId);
expect(dto.championshipId).toBe(championshipId);
expect(dto.championshipName).toBe('Driver Championship');
expect(dto.rows.length).toBeGreaterThan(0);
const rows = dto.rows;
const sorted = [...rows].sort((a, b) => b.totalPoints - a.totalPoints);
expect(rows.map((r) => r.participant.id)).toEqual(
sorted.map((r) => r.participant.id),
);
const leader = rows[0];
expect(leader.resultsCounted).toBeLessThanOrEqual(6);
expect(leader.resultsDropped).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { DropScoreApplier } from '@gridpilot/racing/domain/services/DropScoreApplier';
import type { EventPointsEntry } from '@gridpilot/racing/domain/services/DropScoreApplier';
import type { DropScorePolicy } from '@gridpilot/racing/domain/value-objects/DropScorePolicy';
describe('DropScoreApplier', () => {
it('with strategy none counts all events and drops none', () => {
const applier = new DropScoreApplier();
const policy: DropScorePolicy = {
strategy: 'none',
};
const events: EventPointsEntry[] = [
{ eventId: 'event-1', points: 25 },
{ eventId: 'event-2', points: 18 },
{ eventId: 'event-3', points: 15 },
];
const result = applier.apply(policy, events);
expect(result.counted).toHaveLength(3);
expect(result.dropped).toHaveLength(0);
expect(result.totalPoints).toBe(25 + 18 + 15);
});
it('with bestNResults keeps the highest scoring events and drops the rest', () => {
const applier = new DropScoreApplier();
const policy: DropScorePolicy = {
strategy: 'bestNResults',
count: 6,
};
const events: EventPointsEntry[] = [
{ eventId: 'event-1', points: 25 },
{ eventId: 'event-2', points: 18 },
{ eventId: 'event-3', points: 15 },
{ eventId: 'event-4', points: 12 },
{ eventId: 'event-5', points: 10 },
{ eventId: 'event-6', points: 8 },
{ eventId: 'event-7', points: 6 },
{ eventId: 'event-8', points: 4 },
];
const result = applier.apply(policy, events);
expect(result.counted).toHaveLength(6);
expect(result.dropped).toHaveLength(2);
const countedIds = result.counted.map((e) => e.eventId);
expect(countedIds).toEqual([
'event-1',
'event-2',
'event-3',
'event-4',
'event-5',
'event-6',
]);
const droppedIds = result.dropped.map((e) => e.eventId);
expect(droppedIds).toEqual(['event-7', 'event-8']);
expect(result.totalPoints).toBe(25 + 18 + 15 + 12 + 10 + 8);
});
it('bestNResults with count greater than available events counts all of them', () => {
const applier = new DropScoreApplier();
const policy: DropScorePolicy = {
strategy: 'bestNResults',
count: 10,
};
const events: EventPointsEntry[] = [
{ eventId: 'event-1', points: 25 },
{ eventId: 'event-2', points: 18 },
{ eventId: 'event-3', points: 15 },
];
const result = applier.apply(policy, events);
expect(result.counted).toHaveLength(3);
expect(result.dropped).toHaveLength(0);
expect(result.totalPoints).toBe(25 + 18 + 15);
});
});

View File

@@ -0,0 +1,266 @@
import { describe, it, expect } from 'vitest';
import { EventScoringService } from '@gridpilot/racing/domain/services/EventScoringService';
import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef';
import type { SessionType } from '@gridpilot/racing/domain/value-objects/SessionType';
import { PointsTable } from '@gridpilot/racing/domain/value-objects/PointsTable';
import type { BonusRule } from '@gridpilot/racing/domain/value-objects/BonusRule';
import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig';
import type { Result } from '@gridpilot/racing/domain/entities/Result';
import type { Penalty } from '@gridpilot/racing/domain/entities/Penalty';
import type { ChampionshipType } from '@gridpilot/racing/domain/value-objects/ChampionshipType';
function makeDriverRef(id: string): ParticipantRef {
return {
type: 'driver' as ChampionshipType,
id,
};
}
function makePointsTable(points: number[]): PointsTable {
const pointsByPosition: Record<number, number> = {};
points.forEach((value, index) => {
pointsByPosition[index + 1] = value;
});
return new PointsTable(pointsByPosition);
}
function makeChampionshipConfig(params: {
id: string;
name: string;
sessionTypes: SessionType[];
mainPoints: number[];
sprintPoints?: number[];
mainBonusRules?: BonusRule[];
}): ChampionshipConfig {
const { id, name, sessionTypes, mainPoints, sprintPoints, mainBonusRules } = params;
const pointsTableBySessionType: Record<SessionType, PointsTable> = {} as Record<SessionType, PointsTable>;
sessionTypes.forEach((sessionType) => {
if (sessionType === 'main') {
pointsTableBySessionType[sessionType] = makePointsTable(mainPoints);
} else if (sessionType === 'sprint' && sprintPoints) {
pointsTableBySessionType[sessionType] = makePointsTable(sprintPoints);
} else {
pointsTableBySessionType[sessionType] = new PointsTable({});
}
});
const bonusRulesBySessionType: Record<SessionType, BonusRule[]> = {} as Record<SessionType, BonusRule[]>;
sessionTypes.forEach((sessionType) => {
if (sessionType === 'main' && mainBonusRules) {
bonusRulesBySessionType[sessionType] = mainBonusRules;
} else {
bonusRulesBySessionType[sessionType] = [];
}
});
return {
id,
name,
type: 'driver',
sessionTypes,
pointsTableBySessionType,
bonusRulesBySessionType,
dropScorePolicy: {
strategy: 'none',
},
};
}
describe('EventScoringService', () => {
const seasonId = 'season-1';
it('assigns base points based on finishing positions for a main race', () => {
const service = new EventScoringService();
const championship = makeChampionshipConfig({
id: 'champ-1',
name: 'Driver Championship',
sessionTypes: ['main'],
mainPoints: [25, 18, 15, 12, 10],
});
const results: Result[] = [
{
id: 'result-1',
raceId: 'race-1',
driverId: 'driver-1',
position: 1,
fastestLap: 90000,
incidents: 0,
startPosition: 1,
},
{
id: 'result-2',
raceId: 'race-1',
driverId: 'driver-2',
position: 2,
fastestLap: 90500,
incidents: 0,
startPosition: 2,
},
{
id: 'result-3',
raceId: 'race-1',
driverId: 'driver-3',
position: 3,
fastestLap: 91000,
incidents: 0,
startPosition: 3,
},
{
id: 'result-4',
raceId: 'race-1',
driverId: 'driver-4',
position: 4,
fastestLap: 91500,
incidents: 0,
startPosition: 4,
},
{
id: 'result-5',
raceId: 'race-1',
driverId: 'driver-5',
position: 5,
fastestLap: 92000,
incidents: 0,
startPosition: 5,
},
];
const penalties: Penalty[] = [];
const points = service.scoreSession({
seasonId,
championship,
sessionType: 'main',
results,
penalties,
});
const byParticipant = new Map(points.map((p) => [p.participant.id, p]));
expect(byParticipant.get('driver-1')?.basePoints).toBe(25);
expect(byParticipant.get('driver-2')?.basePoints).toBe(18);
expect(byParticipant.get('driver-3')?.basePoints).toBe(15);
expect(byParticipant.get('driver-4')?.basePoints).toBe(12);
expect(byParticipant.get('driver-5')?.basePoints).toBe(10);
for (const entry of byParticipant.values()) {
expect(entry.bonusPoints).toBe(0);
expect(entry.penaltyPoints).toBe(0);
expect(entry.totalPoints).toBe(entry.basePoints);
}
});
it('applies fastest lap bonus only when inside top 10', () => {
const service = new EventScoringService();
const fastestLapBonus: BonusRule = {
id: 'bonus-fastest-lap',
type: 'fastestLap',
points: 1,
requiresFinishInTopN: 10,
};
const championship = makeChampionshipConfig({
id: 'champ-1',
name: 'Driver Championship',
sessionTypes: ['main'],
mainPoints: [25, 18, 15, 12, 10, 8, 6, 4, 2, 1],
mainBonusRules: [fastestLapBonus],
});
const baseResultTemplate = {
raceId: 'race-1',
incidents: 0,
} as const;
const resultsP11Fastest: Result[] = [
{
id: 'result-1',
...baseResultTemplate,
driverId: 'driver-1',
position: 1,
startPosition: 1,
fastestLap: 91000,
},
{
id: 'result-2',
...baseResultTemplate,
driverId: 'driver-2',
position: 2,
startPosition: 2,
fastestLap: 90500,
},
{
id: 'result-3',
...baseResultTemplate,
driverId: 'driver-3',
position: 11,
startPosition: 15,
fastestLap: 90000,
},
];
const penalties: Penalty[] = [];
const pointsNoBonus = service.scoreSession({
seasonId,
championship,
sessionType: 'main',
results: resultsP11Fastest,
penalties,
});
const mapNoBonus = new Map(pointsNoBonus.map((p) => [p.participant.id, p]));
expect(mapNoBonus.get('driver-3')?.bonusPoints).toBe(0);
const resultsP8Fastest: Result[] = [
{
id: 'result-1',
...baseResultTemplate,
driverId: 'driver-1',
position: 1,
startPosition: 1,
fastestLap: 91000,
},
{
id: 'result-2',
...baseResultTemplate,
driverId: 'driver-2',
position: 2,
startPosition: 2,
fastestLap: 90500,
},
{
id: 'result-3',
...baseResultTemplate,
driverId: 'driver-3',
position: 8,
startPosition: 15,
fastestLap: 90000,
},
];
const pointsWithBonus = service.scoreSession({
seasonId,
championship,
sessionType: 'main',
results: resultsP8Fastest,
penalties,
});
const mapWithBonus = new Map(pointsWithBonus.map((p) => [p.participant.id, p]));
expect(mapWithBonus.get('driver-3')?.bonusPoints).toBe(1);
expect(mapWithBonus.get('driver-3')?.totalPoints).toBe(
(mapWithBonus.get('driver-3')?.basePoints || 0) +
(mapWithBonus.get('driver-3')?.bonusPoints || 0) -
(mapWithBonus.get('driver-3')?.penaltyPoints || 0),
);
});
});

View File

@@ -18,7 +18,9 @@
"apps/*": ["apps/*"],
"@gridpilot/shared-result": ["packages/shared/result/Result.ts"],
"@gridpilot/automation/*": ["packages/automation/*"],
"@gridpilot/testing-support": ["packages/testing-support/index.ts"]
"@gridpilot/testing-support": ["packages/testing-support/index.ts"],
"@gridpilot/media": ["packages/media/index.ts"],
"@gridpilot/demo-infrastructure": ["packages/demo-infrastructure/index.ts"]
},
"types": ["vitest/globals", "node"],
"jsx": "react-jsx"