wip
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
199
apps/website/app/profile/leagues/page.tsx
Normal file
199
apps/website/app/profile/leagues/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
63
apps/website/components/drivers/DriverIdentity.tsx
Normal file
63
apps/website/components/drivers/DriverIdentity.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
“{request.message}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">No‑Shows</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>
|
||||
);
|
||||
|
||||
28
apps/website/components/profile/DriverRatingPill.tsx
Normal file
28
apps/website/components/profile/DriverRatingPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
apps/website/components/profile/DriverSummaryPill.tsx
Normal file
74
apps/website/components/profile/DriverSummaryPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
171
apps/website/components/profile/UserPill.tsx
Normal file
171
apps/website/components/profile/UserPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
78
apps/website/components/teams/TeamLadderRow.tsx
Normal file
78
apps/website/components/teams/TeamLadderRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
182
apps/website/components/ui/Modal.tsx
Normal file
182
apps/website/components/ui/Modal.tsx
Normal 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;
|
||||
}
|
||||
20
apps/website/lib/currentDriver.ts
Normal file
20
apps/website/lib/currentDriver.ts
Normal 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';
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
89
apps/website/lib/leagueMembership.ts
Normal file
89
apps/website/lib/leagueMembership.ts
Normal 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 };
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MembershipRole } from '@/lib/racingLegacyFacade';
|
||||
import type { MembershipRole } from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
|
||||
export type LeagueRole = MembershipRole;
|
||||
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
19
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/demo-infrastructure/index.ts
Normal file
1
packages/demo-infrastructure/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './media/DemoImageServiceAdapter';
|
||||
@@ -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);
|
||||
}
|
||||
15
packages/demo-infrastructure/package.json
Normal file
15
packages/demo-infrastructure/package.json
Normal 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/*"
|
||||
}
|
||||
}
|
||||
13
packages/demo-infrastructure/tsconfig.json
Normal file
13
packages/demo-infrastructure/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../..",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": [
|
||||
"../../packages/demo-infrastructure/**/*.ts",
|
||||
"../../packages/media/**/*.ts"
|
||||
]
|
||||
}
|
||||
6
packages/media/application/ports/ImageServicePort.ts
Normal file
6
packages/media/application/ports/ImageServicePort.ts
Normal 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
1
packages/media/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './application/ports/ImageServicePort';
|
||||
11
packages/media/package.json
Normal file
11
packages/media/package.json
Normal 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/*"
|
||||
}
|
||||
}
|
||||
10
packages/media/tsconfig.json
Normal file
10
packages/media/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
16
packages/racing/application/dto/ChampionshipStandingsDTO.ts
Normal file
16
packages/racing/application/dto/ChampionshipStandingsDTO.ts
Normal 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[];
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
41
packages/racing/domain/entities/ChampionshipStanding.ts
Normal file
41
packages/racing/domain/entities/ChampionshipStanding.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
24
packages/racing/domain/entities/Game.ts
Normal file
24
packages/racing/domain/entities/Game.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
packages/racing/domain/entities/LeagueScoringConfig.ts
Normal file
7
packages/racing/domain/entities/LeagueScoringConfig.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
|
||||
|
||||
export interface LeagueScoringConfig {
|
||||
id: string;
|
||||
seasonId: string;
|
||||
championships: ChampionshipConfig[];
|
||||
}
|
||||
29
packages/racing/domain/entities/Penalty.ts
Normal file
29
packages/racing/domain/entities/Penalty.ts
Normal 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;
|
||||
}
|
||||
77
packages/racing/domain/entities/Season.ts
Normal file
77
packages/racing/domain/entities/Season.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
6
packages/racing/domain/repositories/IGameRepository.ts
Normal file
6
packages/racing/domain/repositories/IGameRepository.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Game } from '../entities/Game';
|
||||
|
||||
export interface IGameRepository {
|
||||
findById(id: string): Promise<Game | null>;
|
||||
findAll(): Promise<Game[]>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { LeagueScoringConfig } from '../entities/LeagueScoringConfig';
|
||||
|
||||
export interface ILeagueScoringConfigRepository {
|
||||
findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null>;
|
||||
}
|
||||
25
packages/racing/domain/repositories/IPenaltyRepository.ts
Normal file
25
packages/racing/domain/repositories/IPenaltyRepository.ts
Normal 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[]>;
|
||||
}
|
||||
6
packages/racing/domain/repositories/ISeasonRepository.ts
Normal file
6
packages/racing/domain/repositories/ISeasonRepository.ts
Normal 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[]>;
|
||||
}
|
||||
71
packages/racing/domain/services/ChampionshipAggregator.ts
Normal file
71
packages/racing/domain/services/ChampionshipAggregator.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
56
packages/racing/domain/services/DropScoreApplier.ts
Normal file
56
packages/racing/domain/services/DropScoreApplier.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
128
packages/racing/domain/services/EventScoringService.ts
Normal file
128
packages/racing/domain/services/EventScoringService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
packages/racing/domain/value-objects/BonusRule.ts
Normal file
8
packages/racing/domain/value-objects/BonusRule.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type BonusRuleType = 'fastestLap' | 'polePosition' | 'mostPositionsGained';
|
||||
|
||||
export interface BonusRule {
|
||||
id: string;
|
||||
type: BonusRuleType;
|
||||
points: number;
|
||||
requiresFinishInTopN?: number;
|
||||
}
|
||||
15
packages/racing/domain/value-objects/ChampionshipConfig.ts
Normal file
15
packages/racing/domain/value-objects/ChampionshipConfig.ts
Normal 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;
|
||||
}
|
||||
1
packages/racing/domain/value-objects/ChampionshipType.ts
Normal file
1
packages/racing/domain/value-objects/ChampionshipType.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ChampionshipType = 'driver' | 'team' | 'nations' | 'trophy';
|
||||
13
packages/racing/domain/value-objects/DropScorePolicy.ts
Normal file
13
packages/racing/domain/value-objects/DropScorePolicy.ts
Normal 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;
|
||||
}
|
||||
6
packages/racing/domain/value-objects/ParticipantRef.ts
Normal file
6
packages/racing/domain/value-objects/ParticipantRef.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { ChampionshipType } from './ChampionshipType';
|
||||
|
||||
export interface ParticipantRef {
|
||||
type: ChampionshipType;
|
||||
id: string;
|
||||
}
|
||||
21
packages/racing/domain/value-objects/PointsTable.ts
Normal file
21
packages/racing/domain/value-objects/PointsTable.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
packages/racing/domain/value-objects/SessionType.ts
Normal file
9
packages/racing/domain/value-objects/SessionType.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type SessionType =
|
||||
| 'practice'
|
||||
| 'qualifying'
|
||||
| 'q1'
|
||||
| 'q2'
|
||||
| 'q3'
|
||||
| 'sprint'
|
||||
| 'main'
|
||||
| 'timeTrial';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
88
tests/unit/domain/services/DropScoreApplier.test.ts
Normal file
88
tests/unit/domain/services/DropScoreApplier.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
266
tests/unit/domain/services/EventScoringService.test.ts
Normal file
266
tests/unit/domain/services/EventScoringService.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user