wip
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
|||||||
EntityMappers,
|
EntityMappers,
|
||||||
type DriverDTO,
|
type DriverDTO,
|
||||||
type LeagueScoringConfigDTO,
|
type LeagueScoringConfigDTO,
|
||||||
|
Race,
|
||||||
} from '@gridpilot/racing';
|
} from '@gridpilot/racing';
|
||||||
import {
|
import {
|
||||||
getLeagueRepository,
|
getLeagueRepository,
|
||||||
@@ -32,9 +33,10 @@ import {
|
|||||||
getSeasonRepository,
|
getSeasonRepository,
|
||||||
getSponsorRepository,
|
getSponsorRepository,
|
||||||
getSeasonSponsorshipRepository,
|
getSeasonSponsorshipRepository,
|
||||||
|
getCompleteRaceUseCase,
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
import { LeagueScoringConfigPresenter } from '@/lib/presenters/LeagueScoringConfigPresenter';
|
import { LeagueScoringConfigPresenter } from '@/lib/presenters/LeagueScoringConfigPresenter';
|
||||||
import { Trophy, Star, ExternalLink } from 'lucide-react';
|
import { Trophy, Star, ExternalLink, Calendar, Users } from 'lucide-react';
|
||||||
import { getMembership, getLeagueMembers } from '@/lib/leagueMembership';
|
import { getMembership, getLeagueMembers } from '@/lib/leagueMembership';
|
||||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||||
import { getLeagueRoleDisplay } from '@/lib/leagueRoles';
|
import { getLeagueRoleDisplay } from '@/lib/leagueRoles';
|
||||||
@@ -62,6 +64,7 @@ export default function LeagueDetailPage() {
|
|||||||
const [averageSOF, setAverageSOF] = useState<number | null>(null);
|
const [averageSOF, setAverageSOF] = useState<number | null>(null);
|
||||||
const [completedRacesCount, setCompletedRacesCount] = useState<number>(0);
|
const [completedRacesCount, setCompletedRacesCount] = useState<number>(0);
|
||||||
const [sponsors, setSponsors] = useState<SponsorInfo[]>([]);
|
const [sponsors, setSponsors] = useState<SponsorInfo[]>([]);
|
||||||
|
const [runningRaces, setRunningRaces] = useState<Race[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
@@ -139,6 +142,11 @@ export default function LeagueDetailPage() {
|
|||||||
|
|
||||||
setDrivers(driverDtos);
|
setDrivers(driverDtos);
|
||||||
|
|
||||||
|
// Load all races for this league to find running ones
|
||||||
|
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
|
||||||
|
const runningRaces = leagueRaces.filter(r => r.status === 'running');
|
||||||
|
setRunningRaces(runningRaces);
|
||||||
|
|
||||||
// Load league stats including average SOF from application use case
|
// Load league stats including average SOF from application use case
|
||||||
await leagueStatsUseCase.execute({ leagueId });
|
await leagueStatsUseCase.execute({ leagueId });
|
||||||
const leagueStatsViewModel = leagueStatsUseCase.presenter.getViewModel();
|
const leagueStatsViewModel = leagueStatsUseCase.presenter.getViewModel();
|
||||||
@@ -147,7 +155,6 @@ export default function LeagueDetailPage() {
|
|||||||
setCompletedRacesCount(leagueStatsViewModel.completedRaces);
|
setCompletedRacesCount(leagueStatsViewModel.completedRaces);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: count completed races manually
|
// Fallback: count completed races manually
|
||||||
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
|
|
||||||
const completedRaces = leagueRaces.filter(r => r.status === 'completed');
|
const completedRaces = leagueRaces.filter(r => r.status === 'completed');
|
||||||
setCompletedRacesCount(completedRaces.length);
|
setCompletedRacesCount(completedRaces.length);
|
||||||
}
|
}
|
||||||
@@ -306,6 +313,88 @@ export default function LeagueDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Live Race Card - Prominently show running races */}
|
||||||
|
{runningRaces.length > 0 && (
|
||||||
|
<Card className="border-2 border-performance-green/50 bg-gradient-to-r from-performance-green/10 to-performance-green/5 mb-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-3 h-3 bg-performance-green rounded-full animate-pulse"></div>
|
||||||
|
<h2 className="text-xl font-bold text-white">🏁 Live Race in Progress</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{runningRaces.map((race) => (
|
||||||
|
<div
|
||||||
|
key={race.id}
|
||||||
|
className="p-4 rounded-lg bg-deep-graphite border border-performance-green/30"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="px-3 py-1 bg-performance-green/20 border border-performance-green/40 rounded-full">
|
||||||
|
<span className="text-sm font-semibold text-performance-green">LIVE</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">
|
||||||
|
{race.track} - {race.car}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => router.push(`/races/${race.id}`)}
|
||||||
|
className="bg-performance-green hover:bg-performance-green/80 text-white"
|
||||||
|
>
|
||||||
|
View Live Race
|
||||||
|
</Button>
|
||||||
|
{membership?.role === 'admin' && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={async () => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'Are you sure you want to end this race and process results?\n\nThis will mark the race as completed and calculate final standings.'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const completeRace = getCompleteRaceUseCase();
|
||||||
|
await completeRace.execute({ raceId: race.id });
|
||||||
|
// Reload league data to reflect the completed race
|
||||||
|
await loadLeagueData();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to complete race');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="border-performance-green/50 text-performance-green hover:bg-performance-green/10"
|
||||||
|
>
|
||||||
|
End Race & Process Results
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-400">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span>Started {new Date(race.scheduledAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
{race.registeredCount && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span>{race.registeredCount} drivers registered</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{race.strengthOfField && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trophy className="w-4 h-4" />
|
||||||
|
<span>SOF: {race.strengthOfField}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Action Card */}
|
{/* Action Card */}
|
||||||
{!membership && !isSponsor && (
|
{!membership && !isSponsor && (
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import Heading from '@/components/ui/Heading';
|
|||||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||||
import FileProtestModal from '@/components/races/FileProtestModal';
|
import FileProtestModal from '@/components/races/FileProtestModal';
|
||||||
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
|
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
|
||||||
import { getGetRaceDetailUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, getCancelRaceUseCase } from '@/lib/di-container';
|
import { getGetRaceDetailUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, getCancelRaceUseCase, getCompleteRaceUseCase } from '@/lib/di-container';
|
||||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||||
|
import { getMembership, isOwnerOrAdmin } from '@/lib/leagueMembership';
|
||||||
import type {
|
import type {
|
||||||
RaceDetailViewModel,
|
RaceDetailViewModel,
|
||||||
RaceDetailEntryViewModel,
|
RaceDetailEntryViewModel,
|
||||||
@@ -49,6 +50,7 @@ export default function RaceDetailPage() {
|
|||||||
const [ratingChange, setRatingChange] = useState<number | null>(null);
|
const [ratingChange, setRatingChange] = useState<number | null>(null);
|
||||||
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
|
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
|
||||||
const [showProtestModal, setShowProtestModal] = useState(false);
|
const [showProtestModal, setShowProtestModal] = useState(false);
|
||||||
|
const [membership, setMembership] = useState<any>(null);
|
||||||
|
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
const isSponsorMode = useSponsorMode();
|
const isSponsorMode = useSponsorMode();
|
||||||
@@ -65,6 +67,13 @@ export default function RaceDetailPage() {
|
|||||||
throw new Error('Race detail not available');
|
throw new Error('Race detail not available');
|
||||||
}
|
}
|
||||||
setViewModel(vm);
|
setViewModel(vm);
|
||||||
|
|
||||||
|
// Fetch league membership for admin controls
|
||||||
|
if (vm.league) {
|
||||||
|
const leagueMembership = getMembership(vm.league.id, currentDriverId);
|
||||||
|
setMembership(leagueMembership);
|
||||||
|
}
|
||||||
|
|
||||||
const userResultRatingChange = vm.userResult?.ratingChange ?? null;
|
const userResultRatingChange = vm.userResult?.ratingChange ?? null;
|
||||||
setRatingChange(userResultRatingChange);
|
setRatingChange(userResultRatingChange);
|
||||||
if (userResultRatingChange === null) {
|
if (userResultRatingChange === null) {
|
||||||
@@ -529,7 +538,7 @@ export default function RaceDetailPage() {
|
|||||||
{animatedRatingChange > 0 ? '+' : ''}
|
{animatedRatingChange > 0 ? '+' : ''}
|
||||||
{animatedRatingChange}
|
{animatedRatingChange}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400 mt-0.5">iRating</div>
|
<div className="text-xs text-gray-400 mt-0.5">Rating</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -717,11 +726,11 @@ export default function RaceDetailPage() {
|
|||||||
className={`
|
className={`
|
||||||
flex items-center justify-center w-8 h-8 rounded-lg font-bold text-sm
|
flex items-center justify-center w-8 h-8 rounded-lg font-bold text-sm
|
||||||
${
|
${
|
||||||
index === 0
|
race.status === 'completed' && index === 0
|
||||||
? 'bg-yellow-500/20 text-yellow-400'
|
? 'bg-yellow-500/20 text-yellow-400'
|
||||||
: index === 1
|
: race.status === 'completed' && index === 1
|
||||||
? 'bg-gray-400/20 text-gray-300'
|
? 'bg-gray-400/20 text-gray-300'
|
||||||
: index === 2
|
: race.status === 'completed' && index === 2
|
||||||
? 'bg-amber-600/20 text-amber-500'
|
? 'bg-amber-600/20 text-amber-500'
|
||||||
: 'bg-iron-gray text-gray-500'
|
: 'bg-iron-gray text-gray-500'
|
||||||
}
|
}
|
||||||
@@ -892,9 +901,55 @@ export default function RaceDetailPage() {
|
|||||||
<Scale className="w-4 h-4" />
|
<Scale className="w-4 h-4" />
|
||||||
Stewarding
|
Stewarding
|
||||||
</Button>
|
</Button>
|
||||||
|
{membership && isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full flex items-center justify-center gap-2"
|
||||||
|
onClick={async () => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'Re-open this race? This will allow re-registration and re-running. Results will be archived.'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
// TODO: Implement re-open race functionality
|
||||||
|
alert('Re-open race functionality not yet implemented');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayCircle className="w-4 h-4" />
|
||||||
|
Re-open Race
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{race.status === 'running' && membership && isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="w-full flex items-center justify-center gap-2"
|
||||||
|
onClick={async () => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'Are you sure you want to end this race and process results?\n\nThis will mark the race as completed and calculate final standings.'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const completeRace = getCompleteRaceUseCase();
|
||||||
|
await completeRace.execute({ raceId: race.id });
|
||||||
|
// Reload race data to reflect the completed race
|
||||||
|
await loadRaceData();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to complete race');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
End Race & Process Results
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{race.status === 'scheduled' && (
|
{race.status === 'scheduled' && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
LogOut,
|
LogOut,
|
||||||
LogIn,
|
LogIn,
|
||||||
|
TrendingUp,
|
||||||
|
Award,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type DemoNotificationType = 'protest_filed' | 'defense_requested' | 'vote_required';
|
type DemoNotificationType = 'protest_filed' | 'defense_requested' | 'vote_required' | 'race_performance_summary' | 'race_final_results';
|
||||||
type DemoUrgency = 'silent' | 'toast' | 'modal';
|
type DemoUrgency = 'silent' | 'toast' | 'modal';
|
||||||
|
|
||||||
interface NotificationOption {
|
interface NotificationOption {
|
||||||
@@ -63,6 +65,20 @@ const notificationOptions: NotificationOption[] = [
|
|||||||
icon: Vote,
|
icon: Vote,
|
||||||
color: 'text-primary-blue',
|
color: 'text-primary-blue',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'race_performance_summary',
|
||||||
|
label: 'Race Performance Summary',
|
||||||
|
description: 'Immediate results after main race',
|
||||||
|
icon: TrendingUp,
|
||||||
|
color: 'text-primary-blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'race_final_results',
|
||||||
|
label: 'Race Final Results',
|
||||||
|
description: 'Final results after stewarding closes',
|
||||||
|
icon: Award,
|
||||||
|
color: 'text-warning-amber',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const urgencyOptions: UrgencyOption[] = [
|
const urgencyOptions: UrgencyOption[] = [
|
||||||
@@ -81,7 +97,7 @@ const urgencyOptions: UrgencyOption[] = [
|
|||||||
{
|
{
|
||||||
urgency: 'modal',
|
urgency: 'modal',
|
||||||
label: 'Modal',
|
label: 'Modal',
|
||||||
description: 'Shows blocking modal (must respond)',
|
description: 'Shows blocking modal (may require response)',
|
||||||
icon: AlertCircle,
|
icon: AlertCircle,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -193,7 +209,7 @@ export default function DevToolbar() {
|
|||||||
|
|
||||||
let title: string;
|
let title: string;
|
||||||
let body: string;
|
let body: string;
|
||||||
let notificationType: 'protest_filed' | 'protest_defense_requested' | 'protest_vote_required';
|
let notificationType: 'protest_filed' | 'protest_defense_requested' | 'protest_vote_required' | 'race_performance_summary' | 'race_final_results';
|
||||||
let actionUrl: string;
|
let actionUrl: string;
|
||||||
|
|
||||||
switch (selectedType) {
|
switch (selectedType) {
|
||||||
@@ -224,14 +240,38 @@ export default function DevToolbar() {
|
|||||||
actionUrl = leagueId ? `/leagues/${leagueId}/stewarding` : '/leagues';
|
actionUrl = leagueId ? `/leagues/${leagueId}/stewarding` : '/leagues';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'race_performance_summary': {
|
||||||
|
const raceId = primaryRace?.id;
|
||||||
|
const leagueId = primaryLeague?.id;
|
||||||
|
title = '🏁 Race Complete: Performance Summary';
|
||||||
|
body =
|
||||||
|
'Your Monza Grand Prix race is finished! You finished P1 with 0 incidents. Provisional rating: +25 points. View full results and standings.';
|
||||||
|
notificationType = 'race_performance_summary';
|
||||||
|
actionUrl = raceId ? `/races/${raceId}` : '/races';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'race_final_results': {
|
||||||
|
const leagueId = primaryLeague?.id;
|
||||||
|
title = '🏆 Final Results: Monza Grand Prix';
|
||||||
|
body =
|
||||||
|
'Stewarding is now closed. Your final result: P1 (+25 rating). No penalties were applied. View championship standings.';
|
||||||
|
notificationType = 'race_final_results';
|
||||||
|
actionUrl = leagueId ? `/leagues/${leagueId}/standings` : '/leagues';
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions =
|
const actions =
|
||||||
selectedUrgency === 'modal'
|
selectedUrgency === 'modal'
|
||||||
? [
|
? selectedType.startsWith('race_')
|
||||||
{ label: 'View Protest', type: 'primary' as const, href: actionUrl, actionId: 'view' },
|
? [
|
||||||
{ label: 'Dismiss', type: 'secondary' as const, actionId: 'dismiss' },
|
{ label: selectedType === 'race_performance_summary' ? '🏁 View Race Results' : '🏆 View Standings', type: 'primary' as const, href: actionUrl, actionId: 'view' },
|
||||||
]
|
{ label: '🎉 Share Achievement', type: 'secondary' as const, actionId: 'share' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ label: 'View Protest', type: 'primary' as const, href: actionUrl, actionId: 'view' },
|
||||||
|
{ label: 'Dismiss', type: 'secondary' as const, actionId: 'dismiss' },
|
||||||
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
await sendNotification.execute({
|
await sendNotification.execute({
|
||||||
@@ -241,13 +281,25 @@ export default function DevToolbar() {
|
|||||||
body,
|
body,
|
||||||
actionUrl,
|
actionUrl,
|
||||||
urgency: selectedUrgency as NotificationUrgency,
|
urgency: selectedUrgency as NotificationUrgency,
|
||||||
requiresResponse: selectedUrgency === 'modal',
|
requiresResponse: selectedUrgency === 'modal' && !selectedType.startsWith('race_'),
|
||||||
actions,
|
actions,
|
||||||
data: {
|
data: {
|
||||||
protestId: `demo-protest-${Date.now()}`,
|
...(selectedType.startsWith('protest_') ? {
|
||||||
|
protestId: `demo-protest-${Date.now()}`,
|
||||||
|
} : {}),
|
||||||
|
...(selectedType.startsWith('race_') ? {
|
||||||
|
raceEventId: `demo-race-event-${Date.now()}`,
|
||||||
|
sessionId: `demo-session-${Date.now()}`,
|
||||||
|
position: 1,
|
||||||
|
positionChange: 0,
|
||||||
|
incidents: 0,
|
||||||
|
provisionalRatingChange: 25,
|
||||||
|
finalRatingChange: 25,
|
||||||
|
hadPenaltiesApplied: false,
|
||||||
|
} : {}),
|
||||||
raceId: primaryRace?.id ?? '',
|
raceId: primaryRace?.id ?? '',
|
||||||
leagueId: primaryLeague?.id ?? '',
|
leagueId: primaryLeague?.id ?? '',
|
||||||
...(notificationDeadline ? { deadline: notificationDeadline } : {}),
|
...(notificationDeadline && selectedType.startsWith('protest_') ? { deadline: notificationDeadline } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -315,7 +367,7 @@ export default function DevToolbar() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<div className="grid grid-cols-2 gap-1">
|
||||||
{notificationOptions.map((option) => {
|
{notificationOptions.map((option) => {
|
||||||
const Icon = option.icon;
|
const Icon = option.icon;
|
||||||
const isSelected = selectedType === option.type;
|
const isSelected = selectedType === option.type;
|
||||||
@@ -436,7 +488,7 @@ export default function DevToolbar() {
|
|||||||
<p className="text-[10px] text-gray-500">
|
<p className="text-[10px] text-gray-500">
|
||||||
<strong className="text-gray-400">Silent:</strong> Notification center only<br/>
|
<strong className="text-gray-400">Silent:</strong> Notification center only<br/>
|
||||||
<strong className="text-gray-400">Toast:</strong> Temporary popup (auto-dismisses)<br/>
|
<strong className="text-gray-400">Toast:</strong> Temporary popup (auto-dismisses)<br/>
|
||||||
<strong className="text-gray-400">Modal:</strong> Blocking popup (requires action)
|
<strong className="text-gray-400">Modal:</strong> Blocking popup (may require action)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
100
apps/website/components/leagues/EndRaceModal.tsx
Normal file
100
apps/website/components/leagues/EndRaceModal.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { AlertTriangle, TestTube, CheckCircle2 } from 'lucide-react';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
|
||||||
|
interface EndRaceModalProps {
|
||||||
|
raceId: string;
|
||||||
|
raceName: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EndRaceModal({ raceId, raceName, onConfirm, onCancel }: EndRaceModalProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-md bg-iron-gray rounded-xl border border-charcoal-outline shadow-2xl">
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-amber/10 border border-warning-amber/20">
|
||||||
|
<TestTube className="w-6 h-6 text-warning-amber" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white">Development Test Function</h2>
|
||||||
|
<p className="text-sm text-gray-400">End Race & Process Results</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-warning-amber mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-1">Development Only Feature</h3>
|
||||||
|
<p className="text-sm text-gray-300 leading-relaxed">
|
||||||
|
This is a development/testing function to simulate ending a race and processing results.
|
||||||
|
It will generate realistic race results, update driver ratings, and calculate final standings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg bg-performance-green/10 border border-performance-green/20">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-performance-green mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-1">What This Does</h3>
|
||||||
|
<ul className="text-sm text-gray-300 space-y-1">
|
||||||
|
<li>• Marks the race as completed</li>
|
||||||
|
<li>• Generates realistic finishing positions</li>
|
||||||
|
<li>• Updates driver ratings based on performance</li>
|
||||||
|
<li>• Calculates championship points</li>
|
||||||
|
<li>• Updates league standings</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Race: <span className="text-white font-medium">{raceName}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
ID: {raceId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="flex-1 bg-performance-green hover:bg-performance-green/80"
|
||||||
|
>
|
||||||
|
<TestTube className="w-4 h-4 mr-2" />
|
||||||
|
Run Test
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-charcoal-outline">
|
||||||
|
<p className="text-xs text-gray-500 text-center">
|
||||||
|
This action cannot be undone. Use only for testing purposes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
apps/website/components/leagues/QuickPenaltyModal.tsx
Normal file
188
apps/website/components/leagues/QuickPenaltyModal.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { getQuickPenaltyUseCase } from '@/lib/di-container';
|
||||||
|
import type { Driver } from '@gridpilot/racing/application';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
interface QuickPenaltyModalProps {
|
||||||
|
raceId: string;
|
||||||
|
drivers: Driver[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INFRACTION_TYPES = [
|
||||||
|
{ value: 'track_limits', label: 'Track Limits', icon: Flag },
|
||||||
|
{ value: 'unsafe_rejoin', label: 'Unsafe Rejoin', icon: AlertTriangle },
|
||||||
|
{ value: 'aggressive_driving', label: 'Aggressive Driving', icon: Zap },
|
||||||
|
{ value: 'false_start', label: 'False Start', icon: Clock },
|
||||||
|
{ value: 'other', label: 'Other', icon: AlertTriangle },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SEVERITY_LEVELS = [
|
||||||
|
{ value: 'warning', label: 'Warning', description: 'Official warning only' },
|
||||||
|
{ value: 'minor', label: 'Minor', description: 'Light penalty' },
|
||||||
|
{ value: 'major', label: 'Major', description: 'Significant penalty' },
|
||||||
|
{ value: 'severe', label: 'Severe', description: 'Heavy penalty' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function QuickPenaltyModal({ raceId, drivers, onClose }: QuickPenaltyModalProps) {
|
||||||
|
const [selectedDriver, setSelectedDriver] = useState<string>('');
|
||||||
|
const [infractionType, setInfractionType] = useState<string>('');
|
||||||
|
const [severity, setSeverity] = useState<string>('');
|
||||||
|
const [notes, setNotes] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedDriver || !infractionType || !severity) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const useCase = getQuickPenaltyUseCase();
|
||||||
|
await useCase.execute({
|
||||||
|
raceId,
|
||||||
|
driverId: selectedDriver,
|
||||||
|
adminId: 'driver-1', // TODO: Get from current user context
|
||||||
|
infractionType: infractionType as any,
|
||||||
|
severity: severity as any,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh the page to show updated results
|
||||||
|
router.refresh();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to apply penalty');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-md bg-iron-gray rounded-xl border border-charcoal-outline shadow-2xl">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Quick Penalty</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Driver Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Driver
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedDriver}
|
||||||
|
onChange={(e) => setSelectedDriver(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:border-primary-blue focus:outline-none"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select driver...</option>
|
||||||
|
{drivers.map((driver) => (
|
||||||
|
<option key={driver.id} value={driver.id}>
|
||||||
|
{driver.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Infraction Type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Infraction Type
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{INFRACTION_TYPES.map(({ value, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setInfractionType(value)}
|
||||||
|
className={`flex items-center gap-2 p-3 rounded-lg border transition-colors ${
|
||||||
|
infractionType === value
|
||||||
|
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
|
||||||
|
: 'border-charcoal-outline bg-deep-graphite text-gray-300 hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Severity */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Severity
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{SEVERITY_LEVELS.map(({ value, label, description }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSeverity(value)}
|
||||||
|
className={`w-full text-left p-3 rounded-lg border transition-colors ${
|
||||||
|
severity === value
|
||||||
|
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
|
||||||
|
: 'border-charcoal-outline bg-deep-graphite text-gray-300 hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{label}</div>
|
||||||
|
<div className="text-xs opacity-75">{description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Notes (Optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Additional details..."
|
||||||
|
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none resize-none"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={loading || !selectedDriver || !infractionType || !severity}
|
||||||
|
>
|
||||||
|
{loading ? 'Applying...' : 'Apply Penalty'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,12 +13,20 @@ import {
|
|||||||
Flag,
|
Flag,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Clock,
|
Clock,
|
||||||
|
TrendingUp,
|
||||||
|
Award,
|
||||||
|
Star,
|
||||||
|
Medal,
|
||||||
|
Target,
|
||||||
|
Zap,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
|
||||||
interface ModalNotificationProps {
|
interface ModalNotificationProps {
|
||||||
notification: Notification;
|
notification: Notification;
|
||||||
onAction: (notification: Notification, actionId?: string) => void;
|
onAction: (notification: Notification, actionId?: string) => void;
|
||||||
|
onDismiss?: (notification: Notification) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationIcons: Record<string, typeof Bell> = {
|
const notificationIcons: Record<string, typeof Bell> = {
|
||||||
@@ -27,6 +35,8 @@ const notificationIcons: Record<string, typeof Bell> = {
|
|||||||
protest_vote_required: Vote,
|
protest_vote_required: Vote,
|
||||||
penalty_issued: AlertTriangle,
|
penalty_issued: AlertTriangle,
|
||||||
race_results_posted: Trophy,
|
race_results_posted: Trophy,
|
||||||
|
race_performance_summary: Medal,
|
||||||
|
race_final_results: Star,
|
||||||
league_invite: Users,
|
league_invite: Users,
|
||||||
race_reminder: Flag,
|
race_reminder: Flag,
|
||||||
};
|
};
|
||||||
@@ -50,17 +60,30 @@ const notificationColors: Record<string, { bg: string; border: string; text: str
|
|||||||
text: 'text-primary-blue',
|
text: 'text-primary-blue',
|
||||||
glow: 'shadow-[0_0_60px_rgba(25,140,255,0.3)]',
|
glow: 'shadow-[0_0_60px_rgba(25,140,255,0.3)]',
|
||||||
},
|
},
|
||||||
penalty_issued: {
|
penalty_issued: {
|
||||||
bg: 'bg-red-500/10',
|
bg: 'bg-red-500/10',
|
||||||
border: 'border-red-500/50',
|
border: 'border-red-500/50',
|
||||||
text: 'text-red-400',
|
text: 'text-red-400',
|
||||||
glow: 'shadow-[0_0_60px_rgba(239,68,68,0.3)]',
|
glow: 'shadow-[0_0_60px_rgba(239,68,68,0.3)]',
|
||||||
},
|
},
|
||||||
|
race_performance_summary: {
|
||||||
|
bg: 'bg-gradient-to-br from-yellow-400/20 via-orange-500/20 to-red-500/20',
|
||||||
|
border: 'border-yellow-400/60',
|
||||||
|
text: 'text-yellow-400',
|
||||||
|
glow: 'shadow-[0_0_80px_rgba(251,191,36,0.4)]',
|
||||||
|
},
|
||||||
|
race_final_results: {
|
||||||
|
bg: 'bg-gradient-to-br from-purple-500/20 via-pink-500/20 to-indigo-500/20',
|
||||||
|
border: 'border-purple-400/60',
|
||||||
|
text: 'text-purple-400',
|
||||||
|
glow: 'shadow-[0_0_80px_rgba(168,85,247,0.4)]',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ModalNotification({
|
export default function ModalNotification({
|
||||||
notification,
|
notification,
|
||||||
onAction,
|
onAction,
|
||||||
|
onDismiss,
|
||||||
}: ModalNotificationProps) {
|
}: ModalNotificationProps) {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -71,6 +94,18 @@ export default function ModalNotification({
|
|||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle ESC key to dismiss
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && onDismiss && !notification.requiresResponse) {
|
||||||
|
onDismiss(notification);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [notification, onDismiss]);
|
||||||
|
|
||||||
const handleAction = (action: NotificationAction) => {
|
const handleAction = (action: NotificationAction) => {
|
||||||
onAction(notification, action.actionId);
|
onAction(notification, action.actionId);
|
||||||
if (action.href) {
|
if (action.href) {
|
||||||
@@ -97,18 +132,25 @@ export default function ModalNotification({
|
|||||||
const deadline = notification.data?.deadline;
|
const deadline = notification.data?.deadline;
|
||||||
const hasDeadline = deadline instanceof Date;
|
const hasDeadline = deadline instanceof Date;
|
||||||
|
|
||||||
|
// Special celebratory styling for race notifications
|
||||||
|
const isRaceNotification = notification.type.startsWith('race_');
|
||||||
|
const isPerformanceSummary = notification.type === 'race_performance_summary';
|
||||||
|
const isFinalResults = notification.type === 'race_final_results';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
fixed inset-0 z-[100] flex items-center justify-center p-4
|
fixed inset-0 z-[100] flex items-center justify-center p-4
|
||||||
transition-all duration-300
|
transition-all duration-300
|
||||||
${isVisible ? 'bg-black/70 backdrop-blur-sm' : 'bg-transparent'}
|
${isVisible ? 'bg-black/70 backdrop-blur-sm' : 'bg-transparent'}
|
||||||
|
${isRaceNotification ? 'bg-gradient-to-br from-black/80 via-indigo-900/10 to-black/80' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
w-full max-w-lg transform transition-all duration-300
|
w-full max-w-lg transform transition-all duration-300
|
||||||
${isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}
|
${isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}
|
||||||
|
${isRaceNotification ? '' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -116,38 +158,71 @@ export default function ModalNotification({
|
|||||||
rounded-2xl border-2 ${colors.border} ${colors.bg}
|
rounded-2xl border-2 ${colors.border} ${colors.bg}
|
||||||
backdrop-blur-md ${colors.glow}
|
backdrop-blur-md ${colors.glow}
|
||||||
overflow-hidden
|
overflow-hidden
|
||||||
|
${isRaceNotification ? 'relative' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Header with pulse animation */}
|
{/* Header with pulse animation */}
|
||||||
<div className={`relative px-6 py-4 ${colors.bg} border-b ${colors.border}`}>
|
<div className={`relative px-6 py-4 ${colors.bg} border-b ${colors.border} ${isRaceNotification ? 'bg-gradient-to-r from-transparent via-yellow-500/10 to-transparent' : ''}`}>
|
||||||
{/* Animated pulse ring */}
|
{/* Subtle pulse ring */}
|
||||||
<div className="absolute top-4 left-6 w-12 h-12">
|
<div className="absolute top-4 left-6 w-12 h-12">
|
||||||
<div className={`absolute inset-0 rounded-full ${colors.bg} animate-ping opacity-20`} />
|
<div className={`absolute inset-0 rounded-full ${colors.bg} opacity-10`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center justify-between">
|
||||||
<div className={`relative p-3 rounded-xl ${colors.bg} border ${colors.border}`}>
|
<div className="flex items-center gap-4">
|
||||||
<Icon className={`w-6 h-6 ${colors.text}`} />
|
<div className={`relative p-3 rounded-xl ${colors.bg} border ${colors.border} ${isRaceNotification ? 'shadow-lg' : ''}`}>
|
||||||
</div>
|
<Icon className={`w-6 h-6 ${colors.text}`} />
|
||||||
<div>
|
</div>
|
||||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
<div>
|
||||||
Action Required
|
<p className={`text-xs font-semibold uppercase tracking-wide ${isRaceNotification ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||||
</p>
|
{isRaceNotification ? (isPerformanceSummary ? '🏁 Race Complete!' : '🏆 Championship Update') : 'Action Required'}
|
||||||
<h2 className="text-xl font-bold text-white">
|
</p>
|
||||||
{notification.title}
|
<h2 className={`text-xl font-bold ${isRaceNotification ? 'text-white' : 'text-white'}`}>
|
||||||
</h2>
|
{notification.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* X button for dismissible notifications */}
|
||||||
|
{onDismiss && !notification.requiresResponse && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDismiss(notification)}
|
||||||
|
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-400 hover:text-white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-6 py-5">
|
<div className={`px-6 py-5 ${isRaceNotification ? 'bg-gradient-to-b from-transparent to-yellow-500/5' : ''}`}>
|
||||||
<p className="text-gray-300 leading-relaxed">
|
<p className={`leading-relaxed ${isRaceNotification ? 'text-white text-lg font-medium' : 'text-gray-300'}`}>
|
||||||
{notification.body}
|
{notification.body}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Race performance stats */}
|
||||||
|
{isRaceNotification && (
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
|
<div className="bg-black/20 rounded-lg p-3 border border-yellow-400/20">
|
||||||
|
<div className="text-xs text-yellow-300 font-medium mb-1">POSITION</div>
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{notification.data?.position === 'DNF' ? 'DNF' : `P${notification.data?.position || '?'}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-black/20 rounded-lg p-3 border border-yellow-400/20">
|
||||||
|
<div className="text-xs text-yellow-300 font-medium mb-1">RATING CHANGE</div>
|
||||||
|
<div className={`text-2xl font-bold ${(notification.data?.provisionalRatingChange || notification.data?.finalRatingChange || 0) >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{(notification.data?.provisionalRatingChange || notification.data?.finalRatingChange || 0) >= 0 ? '+' : ''}
|
||||||
|
{notification.data?.provisionalRatingChange || notification.data?.finalRatingChange || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Deadline warning */}
|
{/* Deadline warning */}
|
||||||
{hasDeadline && (
|
{hasDeadline && !isRaceNotification && (
|
||||||
<div className="mt-4 flex items-center gap-2 px-4 py-3 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
|
<div className="mt-4 flex items-center gap-2 px-4 py-3 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
|
||||||
<Clock className="w-5 h-5 text-warning-amber" />
|
<Clock className="w-5 h-5 text-warning-amber" />
|
||||||
<div>
|
<div>
|
||||||
@@ -168,10 +243,11 @@ export default function ModalNotification({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="px-6 py-4 bg-iron-gray/30 border-t border-charcoal-outline">
|
<div className={`px-6 py-4 border-t ${isRaceNotification ? (isPerformanceSummary ? 'border-yellow-400/60 bg-gradient-to-r from-yellow-500/10 to-orange-500/10' : 'border-purple-400/60 bg-gradient-to-r from-purple-500/10 to-pink-500/10') : 'border-charcoal-outline bg-iron-gray/30'}`}>
|
||||||
{notification.actions && notification.actions.length > 0 ? (
|
{notification.actions && notification.actions.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-3 justify-end">
|
<div className="flex flex-wrap gap-3 justify-end">
|
||||||
{notification.actions.map((action, index) => (
|
{notification.actions.map((action, index) => (
|
||||||
@@ -179,23 +255,48 @@ export default function ModalNotification({
|
|||||||
key={index}
|
key={index}
|
||||||
variant={action.type === 'primary' ? 'primary' : 'secondary'}
|
variant={action.type === 'primary' ? 'primary' : 'secondary'}
|
||||||
onClick={() => handleAction(action)}
|
onClick={() => handleAction(action)}
|
||||||
className={action.type === 'danger' ? 'bg-red-500 hover:bg-red-600 text-white' : ''}
|
className={`${action.type === 'danger' ? 'bg-red-500 hover:bg-red-600 text-white' : ''} ${isRaceNotification ? 'shadow-lg hover:shadow-yellow-400/30' : ''}`}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex justify-end">
|
<div className="flex flex-wrap gap-3 justify-end">
|
||||||
<Button variant="primary" onClick={handlePrimaryAction}>
|
{isRaceNotification ? (
|
||||||
{notification.actionUrl ? 'View Details' : 'Acknowledge'}
|
<>
|
||||||
</Button>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onDismiss ? onDismiss(notification) : handleAction(notification, 'dismiss')}
|
||||||
|
className="shadow-lg hover:shadow-yellow-400/30"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleAction({ label: 'Share Achievement', type: 'secondary', actionId: 'share' })}
|
||||||
|
className="shadow-lg hover:shadow-yellow-400/30"
|
||||||
|
>
|
||||||
|
🎉 Share
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={isPerformanceSummary ? 'race-performance' : 'race-final'}
|
||||||
|
onClick={handlePrimaryAction}
|
||||||
|
>
|
||||||
|
{isPerformanceSummary ? '🏁 View Race Results' : '🏆 View Standings'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant="primary" onClick={handlePrimaryAction}>
|
||||||
|
{notification.actionUrl ? 'View Details' : 'Acknowledge'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cannot dismiss warning */}
|
{/* Cannot dismiss warning */}
|
||||||
{notification.requiresResponse && (
|
{notification.requiresResponse && !isRaceNotification && (
|
||||||
<div className="px-6 py-2 bg-red-500/10 border-t border-red-500/20">
|
<div className="px-6 py-2 bg-red-500/10 border-t border-red-500/20">
|
||||||
<p className="text-xs text-red-400 text-center">
|
<p className="text-xs text-red-400 text-center">
|
||||||
⚠️ This notification requires your action and cannot be dismissed
|
⚠️ This notification requires your action and cannot be dismissed
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface NotificationContextValue {
|
|||||||
markAsRead: (notification: Notification) => Promise<void>;
|
markAsRead: (notification: Notification) => Promise<void>;
|
||||||
dismissToast: (notification: Notification) => void;
|
dismissToast: (notification: Notification) => void;
|
||||||
respondToModal: (notification: Notification, actionId?: string) => Promise<void>;
|
respondToModal: (notification: Notification, actionId?: string) => Promise<void>;
|
||||||
|
dismissModal: (notification: Notification) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationContext = createContext<NotificationContextValue | null>(null);
|
const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||||
@@ -132,6 +133,25 @@ export default function NotificationProvider({ children }: NotificationProviderP
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const dismissModal = useCallback(async (notification: Notification) => {
|
||||||
|
try {
|
||||||
|
// Dismiss the notification
|
||||||
|
const repo = getNotificationRepository();
|
||||||
|
const updated = notification.dismiss();
|
||||||
|
await repo.update(updated);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === notification.id ? updated : n))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear modal
|
||||||
|
setModalNotification(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to dismiss notification:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const unreadCount = notifications.filter((n) => n.isUnread() || n.isActionRequired()).length;
|
const unreadCount = notifications.filter((n) => n.isUnread() || n.isActionRequired()).length;
|
||||||
|
|
||||||
const value: NotificationContextValue = {
|
const value: NotificationContextValue = {
|
||||||
@@ -142,6 +162,7 @@ export default function NotificationProvider({ children }: NotificationProviderP
|
|||||||
markAsRead,
|
markAsRead,
|
||||||
dismissToast,
|
dismissToast,
|
||||||
respondToModal,
|
respondToModal,
|
||||||
|
dismissModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -165,6 +186,7 @@ export default function NotificationProvider({ children }: NotificationProviderP
|
|||||||
<ModalNotification
|
<ModalNotification
|
||||||
notification={modalNotification}
|
notification={modalNotification}
|
||||||
onAction={respondToModal}
|
onAction={respondToModal}
|
||||||
|
onDismiss={dismissModal}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</NotificationContext.Provider>
|
</NotificationContext.Provider>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type ButtonAsLink = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ButtonProps = (ButtonAsButton | ButtonAsLink) & {
|
type ButtonProps = (ButtonAsButton | ButtonAsLink) & {
|
||||||
variant?: 'primary' | 'secondary' | 'danger';
|
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,7 +27,9 @@ export default function Button({
|
|||||||
const variantStyles = {
|
const variantStyles = {
|
||||||
primary: 'bg-primary-blue text-white shadow-[0_0_15px_rgba(25,140,255,0.4)] hover:shadow-[0_0_25px_rgba(25,140,255,0.6)] active:ring-2 active:ring-primary-blue focus-visible:outline-primary-blue',
|
primary: 'bg-primary-blue text-white shadow-[0_0_15px_rgba(25,140,255,0.4)] hover:shadow-[0_0_25px_rgba(25,140,255,0.6)] active:ring-2 active:ring-primary-blue focus-visible:outline-primary-blue',
|
||||||
secondary: 'bg-iron-gray text-white border border-charcoal-outline shadow-[0_0_10px_rgba(25,140,255,0.2)] hover:shadow-[0_0_20px_rgba(25,140,255,0.4)] hover:border-primary-blue focus-visible:outline-primary-blue',
|
secondary: 'bg-iron-gray text-white border border-charcoal-outline shadow-[0_0_10px_rgba(25,140,255,0.2)] hover:shadow-[0_0_20px_rgba(25,140,255,0.4)] hover:border-primary-blue focus-visible:outline-primary-blue',
|
||||||
danger: 'bg-red-600 text-white shadow-[0_0_15px_rgba(248,113,113,0.4)] hover:shadow-[0_0_25px_rgba(248,113,113,0.6)] active:ring-2 active:ring-red-600 focus-visible:outline-red-600'
|
danger: 'bg-red-600 text-white shadow-[0_0_15px_rgba(248,113,113,0.4)] hover:shadow-[0_0_25px_rgba(248,113,113,0.6)] active:ring-2 active:ring-red-600 focus-visible:outline-red-600',
|
||||||
|
'race-performance': 'bg-gradient-to-r from-yellow-400 to-orange-500 text-white shadow-[0_0_15px_rgba(251,191,36,0.4)] hover:shadow-[0_0_25px_rgba(251,191,36,0.6)] hover:from-yellow-500 hover:to-orange-600 active:ring-2 active:ring-yellow-400 focus-visible:outline-yellow-400',
|
||||||
|
'race-final': 'bg-gradient-to-r from-purple-400 to-pink-500 text-white shadow-[0_0_15px_rgba(168,85,247,0.4)] hover:shadow-[0_0_25px_rgba(168,85,247,0.6)] hover:from-purple-500 hover:to-pink-600 active:ring-2 active:ring-purple-400 focus-visible:outline-purple-400'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const classes = `${baseStyles} ${variantStyles[variant]} ${className}`;
|
const classes = `${baseStyles} ${variantStyles[variant]} ${className}`;
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ import { GetDriversLeaderboardUseCase } from '@gridpilot/racing/application/use-
|
|||||||
import { GetTeamsLeaderboardUseCase } from '@gridpilot/racing/application/use-cases/GetTeamsLeaderboardUseCase';
|
import { GetTeamsLeaderboardUseCase } from '@gridpilot/racing/application/use-cases/GetTeamsLeaderboardUseCase';
|
||||||
import { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/use-cases/TransferLeagueOwnershipUseCase';
|
import { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/use-cases/TransferLeagueOwnershipUseCase';
|
||||||
import { CancelRaceUseCase } from '@gridpilot/racing/application/use-cases/CancelRaceUseCase';
|
import { CancelRaceUseCase } from '@gridpilot/racing/application/use-cases/CancelRaceUseCase';
|
||||||
|
import { CompleteRaceUseCase } from '@gridpilot/racing/application/use-cases/CompleteRaceUseCase';
|
||||||
import { ImportRaceResultsUseCase } from '@gridpilot/racing/application/use-cases/ImportRaceResultsUseCase';
|
import { ImportRaceResultsUseCase } from '@gridpilot/racing/application/use-cases/ImportRaceResultsUseCase';
|
||||||
import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter';
|
import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter';
|
||||||
import { RacesPagePresenter } from './presenters/RacesPagePresenter';
|
import { RacesPagePresenter } from './presenters/RacesPagePresenter';
|
||||||
@@ -274,6 +275,21 @@ export function configureDIContainer(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For running races, add registrations (especially for league-5 demo)
|
||||||
|
const runningRaces = seedData.races.filter(r => r.status === 'running');
|
||||||
|
for (const race of runningRaces) {
|
||||||
|
// Add a good number of participants for running races
|
||||||
|
const participantCount = Math.floor(Math.random() * 8) + 12; // 12-20 participants
|
||||||
|
const shuffledDrivers = [...seedData.drivers].sort(() => Math.random() - 0.5).slice(0, participantCount);
|
||||||
|
for (const driver of shuffledDrivers) {
|
||||||
|
seedRaceRegistrations.push({
|
||||||
|
raceId: race.id,
|
||||||
|
driverId: driver.id,
|
||||||
|
registeredAt: new Date(Date.now() - Math.floor(Math.random() * 2) * 24 * 60 * 60 * 1000), // Recent registrations
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
container.registerInstance<IRaceRegistrationRepository>(
|
container.registerInstance<IRaceRegistrationRepository>(
|
||||||
DI_TOKENS.RaceRegistrationRepository,
|
DI_TOKENS.RaceRegistrationRepository,
|
||||||
new InMemoryRaceRegistrationRepository(seedRaceRegistrations)
|
new InMemoryRaceRegistrationRepository(seedRaceRegistrations)
|
||||||
@@ -526,6 +542,22 @@ export function configureDIContainer(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure driver-1 is an admin of league-5 (the demo league with running race)
|
||||||
|
const league5Membership = seededMemberships.find(
|
||||||
|
(m) => m.leagueId === 'league-5' && m.driverId === primaryDriverId,
|
||||||
|
);
|
||||||
|
if (league5Membership) {
|
||||||
|
league5Membership.role = 'admin';
|
||||||
|
} else {
|
||||||
|
seededMemberships.push({
|
||||||
|
leagueId: 'league-5',
|
||||||
|
driverId: primaryDriverId,
|
||||||
|
role: 'admin',
|
||||||
|
status: 'active',
|
||||||
|
joinedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure primary driver owns at least one league
|
// Ensure primary driver owns at least one league
|
||||||
const hasPrimaryOwnerMembership = seededMemberships.some(
|
const hasPrimaryOwnerMembership = seededMemberships.some(
|
||||||
(m) => m.driverId === primaryDriverId && m.role === 'owner',
|
(m) => m.driverId === primaryDriverId && m.role === 'owner',
|
||||||
@@ -606,6 +638,27 @@ export function configureDIContainer(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure driver-1 is an admin of league-5 (the demo league with running race)
|
||||||
|
const league5 = seedData.leagues.find(l => l.id === 'league-5');
|
||||||
|
if (league5) {
|
||||||
|
const existing = seededMemberships.find(
|
||||||
|
(m) => m.leagueId === 'league-5' && m.driverId === primaryDriverId,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.role !== 'owner') {
|
||||||
|
existing.role = 'admin';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seededMemberships.push({
|
||||||
|
leagueId: 'league-5',
|
||||||
|
driverId: primaryDriverId,
|
||||||
|
role: 'admin',
|
||||||
|
status: 'active',
|
||||||
|
joinedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Seed pending join requests
|
// Seed pending join requests
|
||||||
const seededJoinRequests: JoinRequest[] = [];
|
const seededJoinRequests: JoinRequest[] = [];
|
||||||
const demoLeagues = seedData.leagues.slice(0, 6);
|
const demoLeagues = seedData.leagues.slice(0, 6);
|
||||||
@@ -857,6 +910,17 @@ export function configureDIContainer(): void {
|
|||||||
new CancelRaceUseCase(raceRepository)
|
new CancelRaceUseCase(raceRepository)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
container.registerInstance(
|
||||||
|
DI_TOKENS.CompleteRaceUseCase,
|
||||||
|
new CompleteRaceUseCase(
|
||||||
|
raceRepository,
|
||||||
|
raceRegistrationRepository,
|
||||||
|
resultRepository,
|
||||||
|
standingRepository,
|
||||||
|
driverRatingProvider
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.CreateLeagueWithSeasonAndScoringUseCase,
|
DI_TOKENS.CreateLeagueWithSeasonAndScoringUseCase,
|
||||||
new CreateLeagueWithSeasonAndScoringUseCase(
|
new CreateLeagueWithSeasonAndScoringUseCase(
|
||||||
|
|||||||
@@ -345,6 +345,11 @@ class DIContainer {
|
|||||||
return getDIContainer().resolve<CancelRaceUseCase>(DI_TOKENS.CancelRaceUseCase);
|
return getDIContainer().resolve<CancelRaceUseCase>(DI_TOKENS.CancelRaceUseCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get completeRaceUseCase(): import('@gridpilot/racing/application/use-cases/CompleteRaceUseCase').CompleteRaceUseCase {
|
||||||
|
this.ensureInitialized();
|
||||||
|
return getDIContainer().resolve<import('@gridpilot/racing/application/use-cases/CompleteRaceUseCase').CompleteRaceUseCase>(DI_TOKENS.CompleteRaceUseCase);
|
||||||
|
}
|
||||||
|
|
||||||
get importRaceResultsUseCase(): ImportRaceResultsUseCase {
|
get importRaceResultsUseCase(): ImportRaceResultsUseCase {
|
||||||
this.ensureInitialized();
|
this.ensureInitialized();
|
||||||
return getDIContainer().resolve<ImportRaceResultsUseCase>(DI_TOKENS.ImportRaceResultsUseCase);
|
return getDIContainer().resolve<ImportRaceResultsUseCase>(DI_TOKENS.ImportRaceResultsUseCase);
|
||||||
@@ -702,6 +707,10 @@ export function getCancelRaceUseCase(): CancelRaceUseCase {
|
|||||||
return DIContainer.getInstance().cancelRaceUseCase;
|
return DIContainer.getInstance().cancelRaceUseCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCompleteRaceUseCase(): import('@gridpilot/racing/application/use-cases/CompleteRaceUseCase').CompleteRaceUseCase {
|
||||||
|
return DIContainer.getInstance().completeRaceUseCase;
|
||||||
|
}
|
||||||
|
|
||||||
export function getImportRaceResultsUseCase(): ImportRaceResultsUseCase {
|
export function getImportRaceResultsUseCase(): ImportRaceResultsUseCase {
|
||||||
return DIContainer.getInstance().importRaceResultsUseCase;
|
return DIContainer.getInstance().importRaceResultsUseCase;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const DI_TOKENS = {
|
|||||||
CreateLeagueWithSeasonAndScoringUseCase: Symbol.for('CreateLeagueWithSeasonAndScoringUseCase'),
|
CreateLeagueWithSeasonAndScoringUseCase: Symbol.for('CreateLeagueWithSeasonAndScoringUseCase'),
|
||||||
TransferLeagueOwnershipUseCase: Symbol.for('TransferLeagueOwnershipUseCase'),
|
TransferLeagueOwnershipUseCase: Symbol.for('TransferLeagueOwnershipUseCase'),
|
||||||
CancelRaceUseCase: Symbol.for('CancelRaceUseCase'),
|
CancelRaceUseCase: Symbol.for('CancelRaceUseCase'),
|
||||||
|
CompleteRaceUseCase: Symbol.for('CompleteRaceUseCase'),
|
||||||
ImportRaceResultsUseCase: Symbol.for('ImportRaceResultsUseCase'),
|
ImportRaceResultsUseCase: Symbol.for('ImportRaceResultsUseCase'),
|
||||||
|
|
||||||
// Queries - Dashboard
|
// Queries - Dashboard
|
||||||
|
|||||||
142
packages/identity/domain/services/RatingUpdateService.ts
Normal file
142
packages/identity/domain/services/RatingUpdateService.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import type { IDomainService } from '@gridpilot/shared/domain';
|
||||||
|
import type { IUserRatingRepository } from '../repositories/IUserRatingRepository';
|
||||||
|
import { UserRating } from '../value-objects/UserRating';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain Service: RatingUpdateService
|
||||||
|
*
|
||||||
|
* Handles updating user ratings based on various events and performance metrics.
|
||||||
|
* Centralizes rating calculation logic and ensures consistency across the system.
|
||||||
|
*/
|
||||||
|
export class RatingUpdateService implements IDomainService {
|
||||||
|
constructor(
|
||||||
|
private readonly userRatingRepository: IUserRatingRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update driver ratings after race completion
|
||||||
|
*/
|
||||||
|
async updateDriverRatingsAfterRace(
|
||||||
|
driverResults: Array<{
|
||||||
|
driverId: string;
|
||||||
|
position: number;
|
||||||
|
totalDrivers: number;
|
||||||
|
incidents: number;
|
||||||
|
startPosition: number;
|
||||||
|
}>
|
||||||
|
): Promise<void> {
|
||||||
|
for (const result of driverResults) {
|
||||||
|
await this.updateDriverRating(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update individual driver rating based on race result
|
||||||
|
*/
|
||||||
|
private async updateDriverRating(result: {
|
||||||
|
driverId: string;
|
||||||
|
position: number;
|
||||||
|
totalDrivers: number;
|
||||||
|
incidents: number;
|
||||||
|
startPosition: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { driverId, position, totalDrivers, incidents, startPosition } = result;
|
||||||
|
|
||||||
|
// Get or create user rating
|
||||||
|
let userRating = await this.userRatingRepository.findByUserId(driverId);
|
||||||
|
if (!userRating) {
|
||||||
|
userRating = UserRating.create(driverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate performance score (0-100)
|
||||||
|
const performanceScore = this.calculatePerformanceScore(position, totalDrivers, startPosition);
|
||||||
|
|
||||||
|
// Calculate fairness score based on incidents (lower incidents = higher fairness)
|
||||||
|
const fairnessScore = this.calculateFairnessScore(incidents, totalDrivers);
|
||||||
|
|
||||||
|
// Update ratings
|
||||||
|
const updatedRating = userRating
|
||||||
|
.updateDriverRating(performanceScore)
|
||||||
|
.updateFairnessScore(fairnessScore);
|
||||||
|
|
||||||
|
// Save updated rating
|
||||||
|
await this.userRatingRepository.save(updatedRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate performance score based on finishing position and field strength
|
||||||
|
*/
|
||||||
|
private calculatePerformanceScore(
|
||||||
|
position: number,
|
||||||
|
totalDrivers: number,
|
||||||
|
startPosition: number
|
||||||
|
): number {
|
||||||
|
// Base score from finishing position (reverse percentile)
|
||||||
|
const positionScore = ((totalDrivers - position + 1) / totalDrivers) * 100;
|
||||||
|
|
||||||
|
// Bonus for positions gained
|
||||||
|
const positionsGained = startPosition - position;
|
||||||
|
const gainBonus = Math.max(0, positionsGained * 2); // 2 points per position gained
|
||||||
|
|
||||||
|
// Field strength adjustment (harder fields give higher scores for same position)
|
||||||
|
const fieldStrengthMultiplier = 0.8 + (totalDrivers / 50); // Max 1.0 for 30+ drivers
|
||||||
|
|
||||||
|
const rawScore = (positionScore + gainBonus) * fieldStrengthMultiplier;
|
||||||
|
|
||||||
|
// Clamp to 0-100 range
|
||||||
|
return Math.max(0, Math.min(100, rawScore));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate fairness score based on incident involvement
|
||||||
|
*/
|
||||||
|
private calculateFairnessScore(incidents: number, totalDrivers: number): number {
|
||||||
|
// Base fairness score (100 = perfect, 0 = terrible)
|
||||||
|
let fairnessScore = 100;
|
||||||
|
|
||||||
|
// Deduct points for incidents
|
||||||
|
fairnessScore -= incidents * 15; // 15 points per incident
|
||||||
|
|
||||||
|
// Additional deduction for high incident rate relative to field
|
||||||
|
const incidentRate = incidents / totalDrivers;
|
||||||
|
if (incidentRate > 0.5) {
|
||||||
|
fairnessScore -= 20; // Heavy penalty for being involved in many incidents
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp to 0-100 range
|
||||||
|
return Math.max(0, Math.min(100, fairnessScore));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update trust score based on sportsmanship actions
|
||||||
|
*/
|
||||||
|
async updateTrustScore(driverId: string, trustChange: number): Promise<void> {
|
||||||
|
let userRating = await this.userRatingRepository.findByUserId(driverId);
|
||||||
|
if (!userRating) {
|
||||||
|
userRating = UserRating.create(driverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert trust change (-50 to +50) to 0-100 scale
|
||||||
|
const currentTrust = userRating.trust.value;
|
||||||
|
const newTrustValue = Math.max(0, Math.min(100, currentTrust + trustChange));
|
||||||
|
|
||||||
|
const updatedRating = userRating.updateTrustScore(newTrustValue);
|
||||||
|
await this.userRatingRepository.save(updatedRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update steward rating based on protest handling quality
|
||||||
|
*/
|
||||||
|
async updateStewardRating(stewardId: string, ratingChange: number): Promise<void> {
|
||||||
|
let userRating = await this.userRatingRepository.findByUserId(stewardId);
|
||||||
|
if (!userRating) {
|
||||||
|
userRating = UserRating.create(stewardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRating = userRating.steward.value;
|
||||||
|
const newRatingValue = Math.max(0, Math.min(100, currentRating + ratingChange));
|
||||||
|
|
||||||
|
const updatedRating = userRating.updateStewardRating(newRatingValue);
|
||||||
|
await this.userRatingRepository.save(updatedRating);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { NotificationType } from '../../domain/types/NotificationTypes';
|
||||||
|
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||||
|
|
||||||
|
export interface NotificationData {
|
||||||
|
raceEventId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
leagueId?: string;
|
||||||
|
position?: number | 'DNF';
|
||||||
|
positionChange?: number;
|
||||||
|
incidents?: number;
|
||||||
|
provisionalRatingChange?: number;
|
||||||
|
finalRatingChange?: number;
|
||||||
|
hadPenaltiesApplied?: boolean;
|
||||||
|
deadline?: Date;
|
||||||
|
protestId?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationAction {
|
||||||
|
label: string;
|
||||||
|
type: 'primary' | 'secondary' | 'danger';
|
||||||
|
href?: string;
|
||||||
|
actionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendNotificationCommand {
|
||||||
|
recipientId: string;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
channel: NotificationChannel;
|
||||||
|
urgency: 'silent' | 'toast' | 'modal';
|
||||||
|
data?: NotificationData;
|
||||||
|
actionUrl?: string;
|
||||||
|
actions?: NotificationAction[];
|
||||||
|
requiresResponse?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INotificationService {
|
||||||
|
sendNotification(command: SendNotificationCommand): Promise<void>;
|
||||||
|
}
|
||||||
@@ -64,6 +64,8 @@ export type NotificationType =
|
|||||||
| 'race_registration_open' // Race registration is now open
|
| 'race_registration_open' // Race registration is now open
|
||||||
| 'race_reminder' // Race starting soon reminder
|
| 'race_reminder' // Race starting soon reminder
|
||||||
| 'race_results_posted' // Race results are available
|
| 'race_results_posted' // Race results are available
|
||||||
|
| 'race_performance_summary' // Immediate performance summary after main race
|
||||||
|
| 'race_final_results' // Final results after stewarding closes
|
||||||
// League-related
|
// League-related
|
||||||
| 'league_invite' // You were invited to a league
|
| 'league_invite' // You were invited to a league
|
||||||
| 'league_join_request' // Someone requested to join your league
|
| 'league_join_request' // Someone requested to join your league
|
||||||
@@ -102,6 +104,8 @@ export function getNotificationTypeTitle(type: NotificationType): string {
|
|||||||
race_registration_open: 'Registration Open',
|
race_registration_open: 'Registration Open',
|
||||||
race_reminder: 'Race Reminder',
|
race_reminder: 'Race Reminder',
|
||||||
race_results_posted: 'Results Posted',
|
race_results_posted: 'Results Posted',
|
||||||
|
race_performance_summary: 'Performance Summary',
|
||||||
|
race_final_results: 'Final Results',
|
||||||
league_invite: 'League Invitation',
|
league_invite: 'League Invitation',
|
||||||
league_join_request: 'Join Request',
|
league_join_request: 'Join Request',
|
||||||
league_join_approved: 'Request Approved',
|
league_join_approved: 'Request Approved',
|
||||||
@@ -139,6 +143,8 @@ export function getNotificationTypePriority(type: NotificationType): number {
|
|||||||
race_registration_open: 5,
|
race_registration_open: 5,
|
||||||
race_reminder: 8,
|
race_reminder: 8,
|
||||||
race_results_posted: 5,
|
race_results_posted: 5,
|
||||||
|
race_performance_summary: 9, // High priority - immediate race feedback
|
||||||
|
race_final_results: 7, // Medium-high priority - final standings
|
||||||
league_invite: 6,
|
league_invite: 6,
|
||||||
league_join_request: 5,
|
league_join_request: 5,
|
||||||
league_join_approved: 7,
|
league_join_approved: 7,
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { UseCase } from '@gridpilot/shared/application/UseCase';
|
||||||
|
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||||
|
import type { IDomainEventPublisher } from '@gridpilot/shared/domain';
|
||||||
|
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use Case: CloseRaceEventStewardingUseCase
|
||||||
|
*
|
||||||
|
* Scheduled job that checks for race events with expired stewarding windows
|
||||||
|
* and closes them, triggering final results notifications.
|
||||||
|
*
|
||||||
|
* This would typically be run by a scheduled job (e.g., every 5 minutes)
|
||||||
|
* to automatically close stewarding windows based on league configuration.
|
||||||
|
*/
|
||||||
|
export interface CloseRaceEventStewardingCommand {
|
||||||
|
// No parameters needed - finds all expired events automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CloseRaceEventStewardingUseCase
|
||||||
|
implements UseCase<CloseRaceEventStewardingCommand, void, void, void>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly raceEventRepository: IRaceEventRepository,
|
||||||
|
private readonly domainEventPublisher: IDomainEventPublisher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: CloseRaceEventStewardingCommand): Promise<void> {
|
||||||
|
// Find all race events awaiting stewarding that have expired windows
|
||||||
|
const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose();
|
||||||
|
|
||||||
|
for (const raceEvent of expiredEvents) {
|
||||||
|
await this.closeStewardingForRaceEvent(raceEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async closeStewardingForRaceEvent(raceEvent: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Close the stewarding window
|
||||||
|
const closedRaceEvent = raceEvent.closeStewarding();
|
||||||
|
await this.raceEventRepository.update(closedRaceEvent);
|
||||||
|
|
||||||
|
// Get list of participating drivers (would need to be implemented)
|
||||||
|
const driverIds = await this.getParticipatingDriverIds(raceEvent);
|
||||||
|
|
||||||
|
// Check if any penalties were applied during stewarding
|
||||||
|
const hadPenaltiesApplied = await this.checkForAppliedPenalties(raceEvent);
|
||||||
|
|
||||||
|
// Publish domain event to trigger final results notifications
|
||||||
|
const event = new RaceEventStewardingClosedEvent({
|
||||||
|
raceEventId: raceEvent.id,
|
||||||
|
leagueId: raceEvent.leagueId,
|
||||||
|
seasonId: raceEvent.seasonId,
|
||||||
|
closedAt: new Date(),
|
||||||
|
driverIds,
|
||||||
|
hadPenaltiesApplied,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.domainEventPublisher.publish(event);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to close stewarding for race event ${raceEvent.id}:`, error);
|
||||||
|
// In production, this would trigger alerts/monitoring
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getParticipatingDriverIds(raceEvent: any): Promise<string[]> {
|
||||||
|
// In a real implementation, this would query race registrations
|
||||||
|
// For the prototype, we'll return a mock list
|
||||||
|
// This would typically involve:
|
||||||
|
// 1. Get all sessions in the race event
|
||||||
|
// 2. For each session, get registered drivers
|
||||||
|
// 3. Return unique driver IDs across all sessions
|
||||||
|
|
||||||
|
// Mock implementation for prototype
|
||||||
|
return ['driver-1', 'driver-2', 'driver-3']; // Would be dynamic in real implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkForAppliedPenalties(raceEvent: any): Promise<boolean> {
|
||||||
|
// In a real implementation, this would check if any penalties were issued
|
||||||
|
// during the stewarding window for this race event
|
||||||
|
// This would query the penalty repository for penalties related to this race event
|
||||||
|
|
||||||
|
// Mock implementation for prototype - randomly simulate penalties
|
||||||
|
return Math.random() > 0.7; // 30% chance of penalties being applied
|
||||||
|
}
|
||||||
|
}
|
||||||
160
packages/racing/application/use-cases/CompleteRaceUseCase.ts
Normal file
160
packages/racing/application/use-cases/CompleteRaceUseCase.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||||
|
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
|
||||||
|
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||||
|
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||||
|
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
|
||||||
|
import { Result } from '../../domain/entities/Result';
|
||||||
|
import { Standing } from '../../domain/entities/Standing';
|
||||||
|
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use Case: CompleteRaceUseCase
|
||||||
|
*
|
||||||
|
* Encapsulates the workflow for completing a race:
|
||||||
|
* - loads the race by id
|
||||||
|
* - throws if the race does not exist
|
||||||
|
* - delegates completion rules to the Race domain entity
|
||||||
|
* - automatically generates realistic results for registered drivers
|
||||||
|
* - updates league standings
|
||||||
|
* - persists all changes via repositories.
|
||||||
|
*/
|
||||||
|
export interface CompleteRaceCommandDTO {
|
||||||
|
raceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompleteRaceUseCase
|
||||||
|
implements AsyncUseCase<CompleteRaceCommandDTO, void> {
|
||||||
|
constructor(
|
||||||
|
private readonly raceRepository: IRaceRepository,
|
||||||
|
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
|
||||||
|
private readonly resultRepository: IResultRepository,
|
||||||
|
private readonly standingRepository: IStandingRepository,
|
||||||
|
private readonly driverRatingProvider: DriverRatingProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: CompleteRaceCommandDTO): Promise<void> {
|
||||||
|
const { raceId } = command;
|
||||||
|
|
||||||
|
const race = await this.raceRepository.findById(raceId);
|
||||||
|
if (!race) {
|
||||||
|
throw new Error('Race not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get registered drivers for this race
|
||||||
|
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
|
||||||
|
if (registeredDriverIds.length === 0) {
|
||||||
|
throw new Error('Cannot complete race with no registered drivers');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get driver ratings
|
||||||
|
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
|
||||||
|
|
||||||
|
// Generate realistic race results
|
||||||
|
const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings);
|
||||||
|
|
||||||
|
// Save results
|
||||||
|
for (const result of results) {
|
||||||
|
await this.resultRepository.create(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update standings
|
||||||
|
await this.updateStandings(race.leagueId, results);
|
||||||
|
|
||||||
|
// Complete the race
|
||||||
|
const completedRace = race.complete();
|
||||||
|
await this.raceRepository.update(completedRace);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateRaceResults(
|
||||||
|
raceId: string,
|
||||||
|
driverIds: string[],
|
||||||
|
driverRatings: Map<string, number>
|
||||||
|
): Result[] {
|
||||||
|
// Create driver performance data
|
||||||
|
const driverPerformances = driverIds.map(driverId => ({
|
||||||
|
driverId,
|
||||||
|
rating: driverRatings.get(driverId) ?? 1500, // Default rating
|
||||||
|
randomFactor: Math.random() - 0.5, // -0.5 to +0.5 randomization
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sort by performance (rating + randomization)
|
||||||
|
driverPerformances.sort((a, b) => {
|
||||||
|
const perfA = a.rating + (a.randomFactor * 200); // ±100 rating points randomization
|
||||||
|
const perfB = b.rating + (b.randomFactor * 200);
|
||||||
|
return perfB - perfA; // Higher performance first
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate qualifying results for start positions (similar but different from race results)
|
||||||
|
const qualiPerformances = driverPerformances.map(p => ({
|
||||||
|
...p,
|
||||||
|
randomFactor: Math.random() - 0.5, // New randomization for quali
|
||||||
|
}));
|
||||||
|
qualiPerformances.sort((a, b) => {
|
||||||
|
const perfA = a.rating + (a.randomFactor * 150);
|
||||||
|
const perfB = b.rating + (b.randomFactor * 150);
|
||||||
|
return perfB - perfA;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate results
|
||||||
|
const results: Result[] = [];
|
||||||
|
for (let i = 0; i < driverPerformances.length; i++) {
|
||||||
|
const { driverId } = driverPerformances[i];
|
||||||
|
const position = i + 1;
|
||||||
|
const startPosition = qualiPerformances.findIndex(p => p.driverId === driverId) + 1;
|
||||||
|
|
||||||
|
// Generate realistic lap times (90-120 seconds for a lap)
|
||||||
|
const baseLapTime = 90000 + Math.random() * 30000;
|
||||||
|
const positionBonus = (position - 1) * 500; // Winners are faster
|
||||||
|
const fastestLap = Math.round(baseLapTime + positionBonus + Math.random() * 5000);
|
||||||
|
|
||||||
|
// Generate incidents (0-3, higher for lower positions)
|
||||||
|
const incidentProbability = Math.min(0.8, position / driverPerformances.length);
|
||||||
|
const incidents = Math.random() < incidentProbability ? Math.floor(Math.random() * 3) + 1 : 0;
|
||||||
|
|
||||||
|
results.push(
|
||||||
|
Result.create({
|
||||||
|
id: `${raceId}-${driverId}`,
|
||||||
|
raceId,
|
||||||
|
driverId,
|
||||||
|
position,
|
||||||
|
startPosition,
|
||||||
|
fastestLap,
|
||||||
|
incidents,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
|
||||||
|
// Group results by driver
|
||||||
|
const resultsByDriver = new Map<string, Result[]>();
|
||||||
|
for (const result of results) {
|
||||||
|
const existing = resultsByDriver.get(result.driverId) || [];
|
||||||
|
existing.push(result);
|
||||||
|
resultsByDriver.set(result.driverId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or create standings for each driver
|
||||||
|
for (const [driverId, driverResults] of resultsByDriver) {
|
||||||
|
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId);
|
||||||
|
|
||||||
|
if (!standing) {
|
||||||
|
standing = Standing.create({
|
||||||
|
leagueId,
|
||||||
|
driverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all results for this driver (should be just one for this race)
|
||||||
|
for (const result of driverResults) {
|
||||||
|
standing = standing.addRaceResult(result.position, {
|
||||||
|
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.standingRepository.save(standing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||||
|
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
|
||||||
|
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||||
|
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||||
|
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
|
||||||
|
import { Result } from '../../domain/entities/Result';
|
||||||
|
import { Standing } from '../../domain/entities/Standing';
|
||||||
|
import { RaceResultGenerator } from '../utils/RaceResultGenerator';
|
||||||
|
import { RatingUpdateService } from '@gridpilot/identity/domain/services/RatingUpdateService';
|
||||||
|
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced CompleteRaceUseCase that includes rating updates
|
||||||
|
*/
|
||||||
|
export interface CompleteRaceCommandDTO {
|
||||||
|
raceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompleteRaceUseCaseWithRatings
|
||||||
|
implements AsyncUseCase<CompleteRaceCommandDTO, void> {
|
||||||
|
constructor(
|
||||||
|
private readonly raceRepository: IRaceRepository,
|
||||||
|
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
|
||||||
|
private readonly resultRepository: IResultRepository,
|
||||||
|
private readonly standingRepository: IStandingRepository,
|
||||||
|
private readonly driverRatingProvider: DriverRatingProvider,
|
||||||
|
private readonly ratingUpdateService: RatingUpdateService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: CompleteRaceCommandDTO): Promise<void> {
|
||||||
|
const { raceId } = command;
|
||||||
|
|
||||||
|
const race = await this.raceRepository.findById(raceId);
|
||||||
|
if (!race) {
|
||||||
|
throw new Error('Race not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get registered drivers for this race
|
||||||
|
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
|
||||||
|
if (registeredDriverIds.length === 0) {
|
||||||
|
throw new Error('Cannot complete race with no registered drivers');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get driver ratings
|
||||||
|
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
|
||||||
|
|
||||||
|
// Generate realistic race results
|
||||||
|
const results = RaceResultGenerator.generateRaceResults(raceId, registeredDriverIds, driverRatings);
|
||||||
|
|
||||||
|
// Save results
|
||||||
|
for (const result of results) {
|
||||||
|
await this.resultRepository.create(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update standings
|
||||||
|
await this.updateStandings(race.leagueId, results);
|
||||||
|
|
||||||
|
// Update driver ratings based on performance
|
||||||
|
await this.updateDriverRatings(results, registeredDriverIds.length);
|
||||||
|
|
||||||
|
// Complete the race
|
||||||
|
const completedRace = race.complete();
|
||||||
|
await this.raceRepository.update(completedRace);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
|
||||||
|
// Group results by driver
|
||||||
|
const resultsByDriver = new Map<string, Result[]>();
|
||||||
|
for (const result of results) {
|
||||||
|
const existing = resultsByDriver.get(result.driverId) || [];
|
||||||
|
existing.push(result);
|
||||||
|
resultsByDriver.set(result.driverId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or create standings for each driver
|
||||||
|
for (const [driverId, driverResults] of resultsByDriver) {
|
||||||
|
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId);
|
||||||
|
|
||||||
|
if (!standing) {
|
||||||
|
standing = Standing.create({
|
||||||
|
leagueId,
|
||||||
|
driverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all results for this driver (should be just one for this race)
|
||||||
|
for (const result of driverResults) {
|
||||||
|
standing = standing.addRaceResult(result.position, {
|
||||||
|
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.standingRepository.save(standing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateDriverRatings(results: Result[], totalDrivers: number): Promise<void> {
|
||||||
|
const driverResults = results.map(result => ({
|
||||||
|
driverId: result.driverId,
|
||||||
|
position: result.position,
|
||||||
|
totalDrivers,
|
||||||
|
incidents: result.incidents,
|
||||||
|
startPosition: result.startPosition,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.ratingUpdateService.updateDriverRatingsAfterRace(driverResults);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
packages/racing/application/use-cases/QuickPenaltyUseCase.ts
Normal file
138
packages/racing/application/use-cases/QuickPenaltyUseCase.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Use Case: QuickPenaltyUseCase
|
||||||
|
*
|
||||||
|
* Allows league admins to quickly issue common penalties without protest process.
|
||||||
|
* Designed for fast, common penalty scenarios like track limits, warnings, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Penalty, type PenaltyType } from '../../domain/entities/Penalty';
|
||||||
|
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
|
||||||
|
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||||
|
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||||
|
|
||||||
|
export interface QuickPenaltyCommand {
|
||||||
|
raceId: string;
|
||||||
|
driverId: string;
|
||||||
|
adminId: string;
|
||||||
|
infractionType: 'track_limits' | 'unsafe_rejoin' | 'aggressive_driving' | 'false_start' | 'other';
|
||||||
|
severity: 'warning' | 'minor' | 'major' | 'severe';
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QuickPenaltyUseCase
|
||||||
|
implements AsyncUseCase<QuickPenaltyCommand, { penaltyId: string }> {
|
||||||
|
constructor(
|
||||||
|
private readonly penaltyRepository: IPenaltyRepository,
|
||||||
|
private readonly raceRepository: IRaceRepository,
|
||||||
|
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: QuickPenaltyCommand): Promise<{ penaltyId: string }> {
|
||||||
|
// Validate race exists
|
||||||
|
const race = await this.raceRepository.findById(command.raceId);
|
||||||
|
if (!race) {
|
||||||
|
throw new Error('Race not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate admin has authority
|
||||||
|
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
|
||||||
|
const adminMembership = memberships.find(
|
||||||
|
m => m.driverId === command.adminId && m.status === 'active'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!adminMembership || (adminMembership.role !== 'owner' && adminMembership.role !== 'admin')) {
|
||||||
|
throw new Error('Only league owners and admins can issue penalties');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map infraction + severity to penalty type and value
|
||||||
|
const { type, value, reason } = this.mapInfractionToPenalty(
|
||||||
|
command.infractionType,
|
||||||
|
command.severity
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create the penalty
|
||||||
|
const penalty = Penalty.create({
|
||||||
|
id: randomUUID(),
|
||||||
|
leagueId: race.leagueId,
|
||||||
|
raceId: command.raceId,
|
||||||
|
driverId: command.driverId,
|
||||||
|
type,
|
||||||
|
...(value !== undefined ? { value } : {}),
|
||||||
|
reason,
|
||||||
|
issuedBy: command.adminId,
|
||||||
|
status: 'applied', // Quick penalties are applied immediately
|
||||||
|
issuedAt: new Date(),
|
||||||
|
appliedAt: new Date(),
|
||||||
|
...(command.notes !== undefined ? { notes: command.notes } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.penaltyRepository.create(penalty);
|
||||||
|
|
||||||
|
return { penaltyId: penalty.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapInfractionToPenalty(
|
||||||
|
infractionType: QuickPenaltyCommand['infractionType'],
|
||||||
|
severity: QuickPenaltyCommand['severity']
|
||||||
|
): { type: PenaltyType; value?: number; reason: string } {
|
||||||
|
const severityMultipliers = {
|
||||||
|
warning: 1,
|
||||||
|
minor: 2,
|
||||||
|
major: 3,
|
||||||
|
severe: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const multiplier = severityMultipliers[severity];
|
||||||
|
|
||||||
|
switch (infractionType) {
|
||||||
|
case 'track_limits':
|
||||||
|
if (severity === 'warning') {
|
||||||
|
return { type: 'warning', reason: 'Track limits violation - warning' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'points_deduction',
|
||||||
|
value: multiplier,
|
||||||
|
reason: `Track limits violation - ${multiplier} point${multiplier > 1 ? 's' : ''} deducted`
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'unsafe_rejoin':
|
||||||
|
return {
|
||||||
|
type: 'time_penalty',
|
||||||
|
value: 5 * multiplier,
|
||||||
|
reason: `Unsafe rejoining to track - +${5 * multiplier}s time penalty`
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'aggressive_driving':
|
||||||
|
if (severity === 'warning') {
|
||||||
|
return { type: 'warning', reason: 'Aggressive driving - warning' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'points_deduction',
|
||||||
|
value: 2 * multiplier,
|
||||||
|
reason: `Aggressive driving - ${2 * multiplier} point${multiplier > 1 ? 's' : ''} deducted`
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'false_start':
|
||||||
|
return {
|
||||||
|
type: 'grid_penalty',
|
||||||
|
value: multiplier,
|
||||||
|
reason: `False start - ${multiplier} grid position${multiplier > 1 ? 's' : ''} penalty`
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'other':
|
||||||
|
if (severity === 'warning') {
|
||||||
|
return { type: 'warning', reason: 'General infraction - warning' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'points_deduction',
|
||||||
|
value: 3 * multiplier,
|
||||||
|
reason: `General infraction - ${3 * multiplier} point${multiplier > 1 ? 's' : ''} deducted`
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown infraction type: ${infractionType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
packages/racing/application/use-cases/SendFinalResultsUseCase.ts
Normal file
158
packages/racing/application/use-cases/SendFinalResultsUseCase.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import type { UseCase } from '@gridpilot/shared/application/UseCase';
|
||||||
|
import type { INotificationService } from '../../../notifications/application/ports/INotificationService';
|
||||||
|
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||||
|
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||||
|
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
|
||||||
|
import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use Case: SendFinalResultsUseCase
|
||||||
|
*
|
||||||
|
* Triggered by RaceEventStewardingClosed domain event.
|
||||||
|
* Sends final results modal notifications to all drivers who participated,
|
||||||
|
* including any penalty adjustments applied during stewarding.
|
||||||
|
*/
|
||||||
|
export class SendFinalResultsUseCase implements UseCase<RaceEventStewardingClosedEvent, void, void, void> {
|
||||||
|
constructor(
|
||||||
|
private readonly notificationService: INotificationService,
|
||||||
|
private readonly raceEventRepository: IRaceEventRepository,
|
||||||
|
private readonly resultRepository: IResultRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(event: RaceEventStewardingClosedEvent): Promise<void> {
|
||||||
|
const { raceEventId, leagueId, driverIds, hadPenaltiesApplied } = event.eventData;
|
||||||
|
|
||||||
|
// Get race event to include context
|
||||||
|
const raceEvent = await this.raceEventRepository.findById(raceEventId);
|
||||||
|
if (!raceEvent) {
|
||||||
|
console.warn(`RaceEvent ${raceEventId} not found, skipping final results notifications`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get final results for the main race session
|
||||||
|
const mainRaceSession = raceEvent.getMainRaceSession();
|
||||||
|
if (!mainRaceSession) {
|
||||||
|
console.warn(`No main race session found for RaceEvent ${raceEventId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await this.resultRepository.findByRaceId(mainRaceSession.id);
|
||||||
|
|
||||||
|
// Send final results to each participating driver
|
||||||
|
for (const driverId of driverIds) {
|
||||||
|
const driverResult = results.find(r => r.driverId === driverId);
|
||||||
|
|
||||||
|
await this.sendFinalResultsNotification(
|
||||||
|
driverId,
|
||||||
|
raceEvent,
|
||||||
|
driverResult,
|
||||||
|
leagueId,
|
||||||
|
hadPenaltiesApplied
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendFinalResultsNotification(
|
||||||
|
driverId: string,
|
||||||
|
raceEvent: any, // RaceEvent type
|
||||||
|
driverResult: any, // Result type
|
||||||
|
leagueId: string,
|
||||||
|
hadPenaltiesApplied: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const position = driverResult?.position ?? 'DNF';
|
||||||
|
const positionChange = driverResult?.getPositionChange() ?? 0;
|
||||||
|
const incidents = driverResult?.incidents ?? 0;
|
||||||
|
|
||||||
|
// Calculate final rating change (could include penalty adjustments)
|
||||||
|
const finalRatingChange = this.calculateFinalRatingChange(
|
||||||
|
driverResult?.position,
|
||||||
|
driverResult?.incidents,
|
||||||
|
hadPenaltiesApplied
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = `Final Results: ${raceEvent.name}`;
|
||||||
|
const body = this.buildFinalResultsBody(
|
||||||
|
position,
|
||||||
|
positionChange,
|
||||||
|
incidents,
|
||||||
|
finalRatingChange,
|
||||||
|
hadPenaltiesApplied
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.notificationService.sendNotification({
|
||||||
|
recipientId: driverId,
|
||||||
|
type: 'race_final_results' as NotificationType,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
channel: 'in_app',
|
||||||
|
urgency: 'modal',
|
||||||
|
data: {
|
||||||
|
raceEventId: raceEvent.id,
|
||||||
|
sessionId: raceEvent.getMainRaceSession()?.id,
|
||||||
|
leagueId,
|
||||||
|
position,
|
||||||
|
positionChange,
|
||||||
|
incidents,
|
||||||
|
finalRatingChange,
|
||||||
|
hadPenaltiesApplied,
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'View Championship Standings',
|
||||||
|
type: 'primary',
|
||||||
|
href: `/leagues/${leagueId}/standings`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Race Details',
|
||||||
|
type: 'secondary',
|
||||||
|
href: `/leagues/${leagueId}/races/${raceEvent.id}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requiresResponse: false, // Can be dismissed, shows final results
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFinalResultsBody(
|
||||||
|
position: number | 'DNF',
|
||||||
|
positionChange: number,
|
||||||
|
incidents: number,
|
||||||
|
finalRatingChange: number,
|
||||||
|
hadPenaltiesApplied: boolean
|
||||||
|
): string {
|
||||||
|
const positionText = position === 'DNF' ? 'DNF' : `P${position}`;
|
||||||
|
const positionChangeText = positionChange > 0 ? `+${positionChange}` :
|
||||||
|
positionChange < 0 ? `${positionChange}` : '±0';
|
||||||
|
const incidentsText = incidents === 0 ? 'Clean race!' : `${incidents} incident${incidents > 1 ? 's' : ''}`;
|
||||||
|
const ratingText = finalRatingChange >= 0 ?
|
||||||
|
`+${finalRatingChange} rating` :
|
||||||
|
`${finalRatingChange} rating`;
|
||||||
|
const penaltyText = hadPenaltiesApplied ?
|
||||||
|
' (including stewarding adjustments)' : '';
|
||||||
|
|
||||||
|
return `Final result: ${positionText} (${positionChangeText} positions). ${incidentsText} ${ratingText}${penaltyText}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateFinalRatingChange(
|
||||||
|
position?: number,
|
||||||
|
incidents?: number,
|
||||||
|
hadPenaltiesApplied?: boolean
|
||||||
|
): number {
|
||||||
|
if (!position) return -10; // DNF penalty
|
||||||
|
|
||||||
|
// Base calculation (same as provisional)
|
||||||
|
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
|
||||||
|
const positionBonus = Math.max(0, (20 - position) * 2);
|
||||||
|
const incidentPenalty = (incidents ?? 0) * -5;
|
||||||
|
|
||||||
|
let finalChange = baseChange + positionBonus + incidentPenalty;
|
||||||
|
|
||||||
|
// Additional penalty adjustments if stewarding applied penalties
|
||||||
|
if (hadPenaltiesApplied) {
|
||||||
|
// In a real implementation, this would check actual penalties applied
|
||||||
|
// For now, we'll assume some penalties might have been applied
|
||||||
|
finalChange = Math.max(finalChange - 5, -20); // Cap penalty at -20
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalChange;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { UseCase } from '@gridpilot/shared/application/UseCase';
|
||||||
|
import type { INotificationService } from '../../../notifications/application/ports/INotificationService';
|
||||||
|
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||||
|
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||||
|
import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted';
|
||||||
|
import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use Case: SendPerformanceSummaryUseCase
|
||||||
|
*
|
||||||
|
* Triggered by MainRaceCompleted domain event.
|
||||||
|
* Sends immediate performance summary modal notifications to all drivers who participated in the main race.
|
||||||
|
*/
|
||||||
|
export class SendPerformanceSummaryUseCase implements UseCase<MainRaceCompletedEvent, void, void, void> {
|
||||||
|
constructor(
|
||||||
|
private readonly notificationService: INotificationService,
|
||||||
|
private readonly raceEventRepository: IRaceEventRepository,
|
||||||
|
private readonly resultRepository: IResultRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(event: MainRaceCompletedEvent): Promise<void> {
|
||||||
|
const { raceEventId, sessionId, leagueId, driverIds } = event.eventData;
|
||||||
|
|
||||||
|
// Get race event to include context
|
||||||
|
const raceEvent = await this.raceEventRepository.findById(raceEventId);
|
||||||
|
if (!raceEvent) {
|
||||||
|
console.warn(`RaceEvent ${raceEventId} not found, skipping performance summary notifications`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get results for the main race session to calculate performance data
|
||||||
|
const results = await this.resultRepository.findByRaceId(sessionId);
|
||||||
|
|
||||||
|
// Send performance summary to each participating driver
|
||||||
|
for (const driverId of driverIds) {
|
||||||
|
const driverResult = results.find(r => r.driverId === driverId);
|
||||||
|
|
||||||
|
await this.sendPerformanceSummaryNotification(
|
||||||
|
driverId,
|
||||||
|
raceEvent,
|
||||||
|
driverResult,
|
||||||
|
leagueId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendPerformanceSummaryNotification(
|
||||||
|
driverId: string,
|
||||||
|
raceEvent: any, // RaceEvent type
|
||||||
|
driverResult: any, // Result type
|
||||||
|
leagueId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const position = driverResult?.position ?? 'DNF';
|
||||||
|
const positionChange = driverResult?.getPositionChange() ?? 0;
|
||||||
|
const incidents = driverResult?.incidents ?? 0;
|
||||||
|
|
||||||
|
// Calculate provisional rating change (simplified version)
|
||||||
|
const provisionalRatingChange = this.calculateProvisionalRatingChange(
|
||||||
|
driverResult?.position,
|
||||||
|
driverResult?.incidents
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = `Race Complete: ${raceEvent.name}`;
|
||||||
|
const body = this.buildPerformanceSummaryBody(
|
||||||
|
position,
|
||||||
|
positionChange,
|
||||||
|
incidents,
|
||||||
|
provisionalRatingChange
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.notificationService.sendNotification({
|
||||||
|
recipientId: driverId,
|
||||||
|
type: 'race_performance_summary' as NotificationType,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
channel: 'in_app',
|
||||||
|
urgency: 'modal',
|
||||||
|
data: {
|
||||||
|
raceEventId: raceEvent.id,
|
||||||
|
sessionId: raceEvent.getMainRaceSession()?.id,
|
||||||
|
leagueId,
|
||||||
|
position,
|
||||||
|
positionChange,
|
||||||
|
incidents,
|
||||||
|
provisionalRatingChange,
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'View Full Results',
|
||||||
|
type: 'primary',
|
||||||
|
href: `/leagues/${leagueId}/races/${raceEvent.id}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requiresResponse: false, // Can be dismissed, but shows performance data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPerformanceSummaryBody(
|
||||||
|
position: number | 'DNF',
|
||||||
|
positionChange: number,
|
||||||
|
incidents: number,
|
||||||
|
provisionalRatingChange: number
|
||||||
|
): string {
|
||||||
|
const positionText = position === 'DNF' ? 'DNF' : `P${position}`;
|
||||||
|
const positionChangeText = positionChange > 0 ? `+${positionChange}` :
|
||||||
|
positionChange < 0 ? `${positionChange}` : '±0';
|
||||||
|
const incidentsText = incidents === 0 ? 'Clean race!' : `${incidents} incident${incidents > 1 ? 's' : ''}`;
|
||||||
|
const ratingText = provisionalRatingChange >= 0 ?
|
||||||
|
`+${provisionalRatingChange} rating` :
|
||||||
|
`${provisionalRatingChange} rating`;
|
||||||
|
|
||||||
|
return `You finished ${positionText} (${positionChangeText} positions). ${incidentsText} Provisional ${ratingText}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateProvisionalRatingChange(position?: number, incidents?: number): number {
|
||||||
|
if (!position) return -10; // DNF penalty
|
||||||
|
|
||||||
|
// Simplified rating calculation (matches existing GetRaceDetailUseCase logic)
|
||||||
|
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
|
||||||
|
const positionBonus = Math.max(0, (20 - position) * 2);
|
||||||
|
const incidentPenalty = (incidents ?? 0) * -5;
|
||||||
|
|
||||||
|
return baseChange + positionBonus + incidentPenalty;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
packages/racing/application/utils/RaceResultGenerator.ts
Normal file
130
packages/racing/application/utils/RaceResultGenerator.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { Result } from '../../domain/entities/Result';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced race result generator with detailed incident types
|
||||||
|
*/
|
||||||
|
export class RaceResultGenerator {
|
||||||
|
/**
|
||||||
|
* Generate realistic race results with detailed incidents
|
||||||
|
*/
|
||||||
|
static generateRaceResults(
|
||||||
|
raceId: string,
|
||||||
|
driverIds: string[],
|
||||||
|
driverRatings: Map<string, number>
|
||||||
|
): Result[] {
|
||||||
|
// Create driver performance data
|
||||||
|
const driverPerformances = driverIds.map(driverId => ({
|
||||||
|
driverId,
|
||||||
|
rating: driverRatings.get(driverId) ?? 1500, // Default rating
|
||||||
|
randomFactor: Math.random() - 0.5, // -0.5 to +0.5 randomization
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sort by performance (rating + randomization)
|
||||||
|
driverPerformances.sort((a, b) => {
|
||||||
|
const perfA = a.rating + (a.randomFactor * 200); // ±100 rating points randomization
|
||||||
|
const perfB = b.rating + (b.randomFactor * 200);
|
||||||
|
return perfB - perfA; // Higher performance first
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate qualifying results for start positions (similar but different from race results)
|
||||||
|
const qualiPerformances = driverPerformances.map(p => ({
|
||||||
|
...p,
|
||||||
|
randomFactor: Math.random() - 0.5, // New randomization for quali
|
||||||
|
}));
|
||||||
|
qualiPerformances.sort((a, b) => {
|
||||||
|
const perfA = a.rating + (a.randomFactor * 150);
|
||||||
|
const perfB = b.rating + (b.randomFactor * 150);
|
||||||
|
return perfB - perfA;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate results
|
||||||
|
const results: Result[] = [];
|
||||||
|
for (let i = 0; i < driverPerformances.length; i++) {
|
||||||
|
const { driverId } = driverPerformances[i];
|
||||||
|
const position = i + 1;
|
||||||
|
const startPosition = qualiPerformances.findIndex(p => p.driverId === driverId) + 1;
|
||||||
|
|
||||||
|
// Generate realistic lap times (90-120 seconds for a lap)
|
||||||
|
const baseLapTime = 90000 + Math.random() * 30000;
|
||||||
|
const positionBonus = (position - 1) * 500; // Winners are faster
|
||||||
|
const fastestLap = Math.round(baseLapTime + positionBonus + Math.random() * 5000);
|
||||||
|
|
||||||
|
// Generate detailed incidents
|
||||||
|
const incidents = this.generateDetailedIncidents(position, driverPerformances.length);
|
||||||
|
|
||||||
|
results.push(
|
||||||
|
Result.create({
|
||||||
|
id: `${raceId}-${driverId}`,
|
||||||
|
raceId,
|
||||||
|
driverId,
|
||||||
|
position,
|
||||||
|
startPosition,
|
||||||
|
fastestLap,
|
||||||
|
incidents,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate detailed incidents with specific types
|
||||||
|
*/
|
||||||
|
private static generateDetailedIncidents(position: number, totalDrivers: number): number {
|
||||||
|
// Base probability increases for lower positions (more aggressive driving)
|
||||||
|
const baseProbability = Math.min(0.85, position / totalDrivers + 0.1);
|
||||||
|
|
||||||
|
// Add some randomness
|
||||||
|
const randomFactor = Math.random();
|
||||||
|
|
||||||
|
if (randomFactor > baseProbability) {
|
||||||
|
return 0; // Clean race
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine incident severity based on position and randomness
|
||||||
|
const severityRoll = Math.random();
|
||||||
|
|
||||||
|
if (severityRoll < 0.4) {
|
||||||
|
// Minor incident (track limits, small contact)
|
||||||
|
return 1;
|
||||||
|
} else if (severityRoll < 0.7) {
|
||||||
|
// Moderate incident (off-track, contact with damage)
|
||||||
|
return 2;
|
||||||
|
} else if (severityRoll < 0.9) {
|
||||||
|
// Major incident (spin, collision)
|
||||||
|
return 3;
|
||||||
|
} else {
|
||||||
|
// Severe incident (multiple cars involved, safety car)
|
||||||
|
return Math.floor(Math.random() * 2) + 3; // 3-4 incidents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get incident type description for a given incident count
|
||||||
|
*/
|
||||||
|
static getIncidentDescription(incidents: number): string {
|
||||||
|
switch (incidents) {
|
||||||
|
case 0:
|
||||||
|
return 'Clean race';
|
||||||
|
case 1:
|
||||||
|
return 'Track limits violation';
|
||||||
|
case 2:
|
||||||
|
return 'Contact with another car';
|
||||||
|
case 3:
|
||||||
|
return 'Off-track incident';
|
||||||
|
case 4:
|
||||||
|
return 'Collision requiring safety car';
|
||||||
|
default:
|
||||||
|
return `${incidents} incidents`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate incident penalty points for standings
|
||||||
|
*/
|
||||||
|
static getIncidentPenaltyPoints(incidents: number): number {
|
||||||
|
// Each incident deducts points from championship standings
|
||||||
|
return Math.max(0, incidents - 1) * 2; // First incident free, then 2 points each
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { ResultWithIncidents } from '../../domain/entities/ResultWithIncidents';
|
||||||
|
import { RaceIncidents, type IncidentRecord, type IncidentType } from '../../domain/value-objects/RaceIncidents';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced race result generator with detailed incident types
|
||||||
|
*/
|
||||||
|
export class RaceResultGeneratorWithIncidents {
|
||||||
|
/**
|
||||||
|
* Generate realistic race results with detailed incidents
|
||||||
|
*/
|
||||||
|
static generateRaceResults(
|
||||||
|
raceId: string,
|
||||||
|
driverIds: string[],
|
||||||
|
driverRatings: Map<string, number>
|
||||||
|
): ResultWithIncidents[] {
|
||||||
|
// Create driver performance data
|
||||||
|
const driverPerformances = driverIds.map(driverId => ({
|
||||||
|
driverId,
|
||||||
|
rating: driverRatings.get(driverId) ?? 1500, // Default rating
|
||||||
|
randomFactor: Math.random() - 0.5, // -0.5 to +0.5 randomization
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sort by performance (rating + randomization)
|
||||||
|
driverPerformances.sort((a, b) => {
|
||||||
|
const perfA = a.rating + (a.randomFactor * 200); // ±100 rating points randomization
|
||||||
|
const perfB = b.rating + (b.randomFactor * 200);
|
||||||
|
return perfB - perfA; // Higher performance first
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate qualifying results for start positions (similar but different from race results)
|
||||||
|
const qualiPerformances = driverPerformances.map(p => ({
|
||||||
|
...p,
|
||||||
|
randomFactor: Math.random() - 0.5, // New randomization for quali
|
||||||
|
}));
|
||||||
|
qualiPerformances.sort((a, b) => {
|
||||||
|
const perfA = a.rating + (a.randomFactor * 150);
|
||||||
|
const perfB = b.rating + (b.randomFactor * 150);
|
||||||
|
return perfB - perfA;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate results
|
||||||
|
const results: ResultWithIncidents[] = [];
|
||||||
|
for (let i = 0; i < driverPerformances.length; i++) {
|
||||||
|
const { driverId } = driverPerformances[i];
|
||||||
|
const position = i + 1;
|
||||||
|
const startPosition = qualiPerformances.findIndex(p => p.driverId === driverId) + 1;
|
||||||
|
|
||||||
|
// Generate realistic lap times (90-120 seconds for a lap)
|
||||||
|
const baseLapTime = 90000 + Math.random() * 30000;
|
||||||
|
const positionBonus = (position - 1) * 500; // Winners are faster
|
||||||
|
const fastestLap = Math.round(baseLapTime + positionBonus + Math.random() * 5000);
|
||||||
|
|
||||||
|
// Generate detailed incidents
|
||||||
|
const incidents = this.generateDetailedIncidents(position, driverPerformances.length);
|
||||||
|
|
||||||
|
results.push(
|
||||||
|
ResultWithIncidents.create({
|
||||||
|
id: `${raceId}-${driverId}`,
|
||||||
|
raceId,
|
||||||
|
driverId,
|
||||||
|
position,
|
||||||
|
startPosition,
|
||||||
|
fastestLap,
|
||||||
|
incidents,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate detailed incidents with specific types and severity
|
||||||
|
*/
|
||||||
|
private static generateDetailedIncidents(position: number, totalDrivers: number): RaceIncidents {
|
||||||
|
// Base probability increases for lower positions (more aggressive driving)
|
||||||
|
const baseProbability = Math.min(0.85, position / totalDrivers + 0.1);
|
||||||
|
|
||||||
|
// Add some randomness
|
||||||
|
const randomFactor = Math.random();
|
||||||
|
|
||||||
|
if (randomFactor > baseProbability) {
|
||||||
|
// Clean race
|
||||||
|
return new RaceIncidents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine number of incidents based on position and severity
|
||||||
|
const severityRoll = Math.random();
|
||||||
|
let incidentCount: number;
|
||||||
|
|
||||||
|
if (severityRoll < 0.5) {
|
||||||
|
incidentCount = 1; // Minor incident
|
||||||
|
} else if (severityRoll < 0.8) {
|
||||||
|
incidentCount = 2; // Moderate incident
|
||||||
|
} else if (severityRoll < 0.95) {
|
||||||
|
incidentCount = 3; // Major incident
|
||||||
|
} else {
|
||||||
|
incidentCount = Math.floor(Math.random() * 2) + 3; // 3-4 incidents (severe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate specific incidents
|
||||||
|
const incidents: IncidentRecord[] = [];
|
||||||
|
for (let i = 0; i < incidentCount; i++) {
|
||||||
|
const incidentType = this.selectIncidentType(position, totalDrivers, i);
|
||||||
|
const lap = this.selectIncidentLap(i + 1, incidentCount);
|
||||||
|
|
||||||
|
incidents.push({
|
||||||
|
type: incidentType,
|
||||||
|
lap,
|
||||||
|
description: this.generateIncidentDescription(incidentType),
|
||||||
|
penaltyPoints: this.getPenaltyPoints(incidentType),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RaceIncidents(incidents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select appropriate incident type based on context
|
||||||
|
*/
|
||||||
|
private static selectIncidentType(position: number, totalDrivers: number, incidentIndex: number): IncidentType {
|
||||||
|
// Different incident types have different probabilities
|
||||||
|
const incidentProbabilities: Array<{ type: IncidentType; weight: number }> = [
|
||||||
|
{ type: 'track_limits', weight: 40 }, // Most common
|
||||||
|
{ type: 'contact', weight: 25 }, // Common in traffic
|
||||||
|
{ type: 'unsafe_rejoin', weight: 15 }, // Dangerous
|
||||||
|
{ type: 'aggressive_driving', weight: 10 }, // Less common
|
||||||
|
{ type: 'collision', weight: 5 }, // Rare
|
||||||
|
{ type: 'spin', weight: 4 }, // Rare
|
||||||
|
{ type: 'false_start', weight: 1 }, // Very rare in race
|
||||||
|
];
|
||||||
|
|
||||||
|
// Adjust weights based on position (lower positions more likely to have contact/aggressive driving)
|
||||||
|
if (position > totalDrivers * 0.7) { // Bottom 30%
|
||||||
|
incidentProbabilities.find(p => p.type === 'contact')!.weight += 10;
|
||||||
|
incidentProbabilities.find(p => p.type === 'aggressive_driving')!.weight += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select based on weights
|
||||||
|
const totalWeight = incidentProbabilities.reduce((sum, p) => sum + p.weight, 0);
|
||||||
|
let random = Math.random() * totalWeight;
|
||||||
|
|
||||||
|
for (const { type, weight } of incidentProbabilities) {
|
||||||
|
random -= weight;
|
||||||
|
if (random <= 0) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'track_limits'; // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select appropriate lap for incident
|
||||||
|
*/
|
||||||
|
private static selectIncidentLap(incidentNumber: number, totalIncidents: number): number {
|
||||||
|
// Spread incidents throughout the race
|
||||||
|
const raceLaps = 20; // Assume 20 lap race
|
||||||
|
const lapRanges = [
|
||||||
|
{ min: 1, max: 5 }, // Early race
|
||||||
|
{ min: 6, max: 12 }, // Mid race
|
||||||
|
{ min: 13, max: 20 }, // Late race
|
||||||
|
];
|
||||||
|
|
||||||
|
// Distribute incidents across race phases
|
||||||
|
const phaseIndex = Math.min(incidentNumber - 1, lapRanges.length - 1);
|
||||||
|
const range = lapRanges[phaseIndex];
|
||||||
|
|
||||||
|
return Math.floor(Math.random() * (range.max - range.min + 1)) + range.min;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate human-readable description for incident
|
||||||
|
*/
|
||||||
|
private static generateIncidentDescription(type: IncidentType): string {
|
||||||
|
const descriptions: Record<IncidentType, string[]> = {
|
||||||
|
track_limits: [
|
||||||
|
'Went off track at corner exit',
|
||||||
|
'Cut corner to maintain position',
|
||||||
|
'Ran wide under braking',
|
||||||
|
'Off-track excursion gaining advantage',
|
||||||
|
],
|
||||||
|
contact: [
|
||||||
|
'Light contact while defending position',
|
||||||
|
'Side-by-side contact into corner',
|
||||||
|
'Rear-end contact under braking',
|
||||||
|
'Wheel-to-wheel contact',
|
||||||
|
],
|
||||||
|
unsafe_rejoin: [
|
||||||
|
'Unsafe rejoin across track',
|
||||||
|
'Rejoined directly into racing line',
|
||||||
|
'Failed to check mirrors before rejoining',
|
||||||
|
'Forced another driver off track on rejoin',
|
||||||
|
],
|
||||||
|
aggressive_driving: [
|
||||||
|
'Multiple defensive moves under braking',
|
||||||
|
'Moved under braking three times',
|
||||||
|
'Aggressive defending forcing driver wide',
|
||||||
|
'Persistent blocking maneuvers',
|
||||||
|
],
|
||||||
|
collision: [
|
||||||
|
'Collision involving multiple cars',
|
||||||
|
'Major contact causing safety car',
|
||||||
|
'Chain reaction collision',
|
||||||
|
'Heavy impact collision',
|
||||||
|
],
|
||||||
|
spin: [
|
||||||
|
'Lost control and spun',
|
||||||
|
'Oversteer spin into gravel',
|
||||||
|
'Spin following contact',
|
||||||
|
'Lost rear grip and spun',
|
||||||
|
],
|
||||||
|
false_start: [
|
||||||
|
'Jumped start before green flag',
|
||||||
|
'Early launch from grid',
|
||||||
|
'Premature start',
|
||||||
|
],
|
||||||
|
mechanical: [
|
||||||
|
'Engine failure',
|
||||||
|
'Gearbox issue',
|
||||||
|
'Brake failure',
|
||||||
|
'Suspension damage',
|
||||||
|
],
|
||||||
|
other: [
|
||||||
|
'Unspecified incident',
|
||||||
|
'Race incident',
|
||||||
|
'Driving infraction',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = descriptions[type] || descriptions.other;
|
||||||
|
return options[Math.floor(Math.random() * options.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get penalty points for incident type
|
||||||
|
*/
|
||||||
|
private static getPenaltyPoints(type: IncidentType): number {
|
||||||
|
const penalties: Record<IncidentType, number> = {
|
||||||
|
track_limits: 0, // Usually warning only
|
||||||
|
contact: 2, // Light penalty
|
||||||
|
unsafe_rejoin: 3, // Moderate penalty
|
||||||
|
aggressive_driving: 2, // Light penalty
|
||||||
|
false_start: 5, // Heavy penalty
|
||||||
|
collision: 5, // Heavy penalty
|
||||||
|
spin: 0, // Usually no penalty if no contact
|
||||||
|
mechanical: 0, // Not driver fault
|
||||||
|
other: 2, // Default penalty
|
||||||
|
};
|
||||||
|
return penalties[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get incident description for display
|
||||||
|
*/
|
||||||
|
static getIncidentDescription(incidents: RaceIncidents): string {
|
||||||
|
return incidents.getSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate incident penalty points for standings
|
||||||
|
*/
|
||||||
|
static getIncidentPenaltyPoints(incidents: RaceIncidents): number {
|
||||||
|
return incidents.getTotalPenaltyPoints();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,7 @@
|
|||||||
|
|
||||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||||
import type { IEntity } from '@gridpilot/shared/domain';
|
import type { IEntity } from '@gridpilot/shared/domain';
|
||||||
|
import type { SessionType } from '../value-objects/SessionType';
|
||||||
export type SessionType = 'practice' | 'qualifying' | 'race';
|
|
||||||
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
|
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
export class Race implements IEntity<string> {
|
export class Race implements IEntity<string> {
|
||||||
@@ -80,7 +79,7 @@ export class Race implements IEntity<string> {
|
|||||||
...(props.trackId !== undefined ? { trackId: props.trackId } : {}),
|
...(props.trackId !== undefined ? { trackId: props.trackId } : {}),
|
||||||
car: props.car,
|
car: props.car,
|
||||||
...(props.carId !== undefined ? { carId: props.carId } : {}),
|
...(props.carId !== undefined ? { carId: props.carId } : {}),
|
||||||
sessionType: props.sessionType ?? 'race',
|
sessionType: props.sessionType ?? SessionType.main(),
|
||||||
status: props.status ?? 'scheduled',
|
status: props.status ?? 'scheduled',
|
||||||
...(props.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}),
|
...(props.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}),
|
||||||
...(props.registeredCount !== undefined ? { registeredCount: props.registeredCount } : {}),
|
...(props.registeredCount !== undefined ? { registeredCount: props.registeredCount } : {}),
|
||||||
|
|||||||
283
packages/racing/domain/entities/RaceEvent.ts
Normal file
283
packages/racing/domain/entities/RaceEvent.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
/**
|
||||||
|
* Domain Entity: RaceEvent (Aggregate Root)
|
||||||
|
*
|
||||||
|
* Represents a race event containing multiple sessions (practice, quali, race).
|
||||||
|
* Immutable aggregate root with factory methods and domain validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||||
|
import type { IEntity } from '@gridpilot/shared/domain';
|
||||||
|
import type { Session } from './Session';
|
||||||
|
import type { SessionType } from '../value-objects/SessionType';
|
||||||
|
|
||||||
|
export type RaceEventStatus = 'scheduled' | 'in_progress' | 'awaiting_stewarding' | 'closed' | 'cancelled';
|
||||||
|
|
||||||
|
export class RaceEvent implements IEntity<string> {
|
||||||
|
readonly id: string;
|
||||||
|
readonly seasonId: string;
|
||||||
|
readonly leagueId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly sessions: readonly Session[];
|
||||||
|
readonly status: RaceEventStatus;
|
||||||
|
readonly stewardingClosesAt: Date | undefined;
|
||||||
|
|
||||||
|
private constructor(props: {
|
||||||
|
id: string;
|
||||||
|
seasonId: string;
|
||||||
|
leagueId: string;
|
||||||
|
name: string;
|
||||||
|
sessions: readonly Session[];
|
||||||
|
status: RaceEventStatus;
|
||||||
|
stewardingClosesAt?: Date;
|
||||||
|
}) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.seasonId = props.seasonId;
|
||||||
|
this.leagueId = props.leagueId;
|
||||||
|
this.name = props.name;
|
||||||
|
this.sessions = props.sessions;
|
||||||
|
this.status = props.status;
|
||||||
|
this.stewardingClosesAt = props.stewardingClosesAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method to create a new RaceEvent entity
|
||||||
|
*/
|
||||||
|
static create(props: {
|
||||||
|
id: string;
|
||||||
|
seasonId: string;
|
||||||
|
leagueId: string;
|
||||||
|
name: string;
|
||||||
|
sessions: Session[];
|
||||||
|
status?: RaceEventStatus;
|
||||||
|
stewardingClosesAt?: Date;
|
||||||
|
}): RaceEvent {
|
||||||
|
this.validate(props);
|
||||||
|
|
||||||
|
return new RaceEvent({
|
||||||
|
id: props.id,
|
||||||
|
seasonId: props.seasonId,
|
||||||
|
leagueId: props.leagueId,
|
||||||
|
name: props.name,
|
||||||
|
sessions: [...props.sessions], // Create immutable copy
|
||||||
|
status: props.status ?? 'scheduled',
|
||||||
|
...(props.stewardingClosesAt !== undefined ? { stewardingClosesAt: props.stewardingClosesAt } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain validation logic
|
||||||
|
*/
|
||||||
|
private static validate(props: {
|
||||||
|
id: string;
|
||||||
|
seasonId: string;
|
||||||
|
leagueId: string;
|
||||||
|
name: string;
|
||||||
|
sessions: Session[];
|
||||||
|
}): void {
|
||||||
|
if (!props.id || props.id.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('RaceEvent ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.seasonId || props.seasonId.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('Season ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('League ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.name || props.name.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('RaceEvent name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.sessions || props.sessions.length === 0) {
|
||||||
|
throw new RacingDomainValidationError('RaceEvent must have at least one session');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all sessions belong to this race event
|
||||||
|
const invalidSessions = props.sessions.filter(s => s.raceEventId !== props.id);
|
||||||
|
if (invalidSessions.length > 0) {
|
||||||
|
throw new RacingDomainValidationError('All sessions must belong to this race event');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate session types are unique
|
||||||
|
const sessionTypes = props.sessions.map(s => s.sessionType.value);
|
||||||
|
const uniqueTypes = new Set(sessionTypes);
|
||||||
|
if (uniqueTypes.size !== sessionTypes.length) {
|
||||||
|
throw new RacingDomainValidationError('Session types must be unique within a race event');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate at least one main race session exists
|
||||||
|
const hasMainRace = props.sessions.some(s => s.sessionType.value === 'main');
|
||||||
|
if (!hasMainRace) {
|
||||||
|
throw new RacingDomainValidationError('RaceEvent must have at least one main race session');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the race event (move from scheduled to in_progress)
|
||||||
|
*/
|
||||||
|
start(): RaceEvent {
|
||||||
|
if (this.status !== 'scheduled') {
|
||||||
|
throw new RacingDomainInvariantError('Only scheduled race events can be started');
|
||||||
|
}
|
||||||
|
|
||||||
|
return RaceEvent.create({
|
||||||
|
id: this.id,
|
||||||
|
seasonId: this.seasonId,
|
||||||
|
leagueId: this.leagueId,
|
||||||
|
name: this.name,
|
||||||
|
sessions: this.sessions,
|
||||||
|
status: 'in_progress',
|
||||||
|
stewardingClosesAt: this.stewardingClosesAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete the main race session and move to awaiting_stewarding
|
||||||
|
*/
|
||||||
|
completeMainRace(): RaceEvent {
|
||||||
|
if (this.status !== 'in_progress') {
|
||||||
|
throw new RacingDomainInvariantError('Only in-progress race events can complete main race');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainRaceSession = this.getMainRaceSession();
|
||||||
|
if (!mainRaceSession || mainRaceSession.status !== 'completed') {
|
||||||
|
throw new RacingDomainInvariantError('Main race session must be completed first');
|
||||||
|
}
|
||||||
|
|
||||||
|
return RaceEvent.create({
|
||||||
|
id: this.id,
|
||||||
|
seasonId: this.seasonId,
|
||||||
|
leagueId: this.leagueId,
|
||||||
|
name: this.name,
|
||||||
|
sessions: this.sessions,
|
||||||
|
status: 'awaiting_stewarding',
|
||||||
|
stewardingClosesAt: this.stewardingClosesAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close stewarding and finalize the race event
|
||||||
|
*/
|
||||||
|
closeStewarding(): RaceEvent {
|
||||||
|
if (this.status !== 'awaiting_stewarding') {
|
||||||
|
throw new RacingDomainInvariantError('Only race events awaiting stewarding can be closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return RaceEvent.create({
|
||||||
|
id: this.id,
|
||||||
|
seasonId: this.seasonId,
|
||||||
|
leagueId: this.leagueId,
|
||||||
|
name: this.name,
|
||||||
|
sessions: this.sessions,
|
||||||
|
status: 'closed',
|
||||||
|
stewardingClosesAt: this.stewardingClosesAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the race event
|
||||||
|
*/
|
||||||
|
cancel(): RaceEvent {
|
||||||
|
if (this.status === 'closed') {
|
||||||
|
throw new RacingDomainInvariantError('Cannot cancel a closed race event');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.status === 'cancelled') {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RaceEvent.create({
|
||||||
|
id: this.id,
|
||||||
|
seasonId: this.seasonId,
|
||||||
|
leagueId: this.leagueId,
|
||||||
|
name: this.name,
|
||||||
|
sessions: this.sessions,
|
||||||
|
status: 'cancelled',
|
||||||
|
stewardingClosesAt: this.stewardingClosesAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the main race session (the one that counts for championship points)
|
||||||
|
*/
|
||||||
|
getMainRaceSession(): Session | undefined {
|
||||||
|
return this.sessions.find(s => s.sessionType.equals(SessionType.main()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all sessions of a specific type
|
||||||
|
*/
|
||||||
|
getSessionsByType(sessionType: SessionType): Session[] {
|
||||||
|
return this.sessions.filter(s => s.sessionType.equals(sessionType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all completed sessions
|
||||||
|
*/
|
||||||
|
getCompletedSessions(): Session[] {
|
||||||
|
return this.sessions.filter(s => s.status === 'completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if all sessions are completed
|
||||||
|
*/
|
||||||
|
areAllSessionsCompleted(): boolean {
|
||||||
|
return this.sessions.every(s => s.status === 'completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the main race is completed
|
||||||
|
*/
|
||||||
|
isMainRaceCompleted(): boolean {
|
||||||
|
const mainRace = this.getMainRaceSession();
|
||||||
|
return mainRace?.status === 'completed' ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if stewarding window has expired
|
||||||
|
*/
|
||||||
|
hasStewardingExpired(): boolean {
|
||||||
|
if (!this.stewardingClosesAt) return false;
|
||||||
|
return new Date() > this.stewardingClosesAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if race event is in the past
|
||||||
|
*/
|
||||||
|
isPast(): boolean {
|
||||||
|
const latestSession = this.sessions.reduce((latest, session) =>
|
||||||
|
session.scheduledAt > latest.scheduledAt ? session : latest
|
||||||
|
);
|
||||||
|
return latestSession.scheduledAt < new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if race event is upcoming
|
||||||
|
*/
|
||||||
|
isUpcoming(): boolean {
|
||||||
|
return this.status === 'scheduled' && !this.isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if race event is currently running
|
||||||
|
*/
|
||||||
|
isLive(): boolean {
|
||||||
|
return this.status === 'in_progress';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if race event is awaiting stewarding decisions
|
||||||
|
*/
|
||||||
|
isAwaitingStewarding(): boolean {
|
||||||
|
return this.status === 'awaiting_stewarding';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if race event is closed (stewarding complete)
|
||||||
|
*/
|
||||||
|
isClosed(): boolean {
|
||||||
|
return this.status === 'closed';
|
||||||
|
}
|
||||||
|
}
|
||||||
175
packages/racing/domain/entities/ResultWithIncidents.ts
Normal file
175
packages/racing/domain/entities/ResultWithIncidents.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Enhanced Result entity with detailed incident tracking
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IEntity } from '@gridpilot/shared/domain';
|
||||||
|
import { RaceIncidents, type IncidentRecord } from '../value-objects/RaceIncidents';
|
||||||
|
|
||||||
|
export class ResultWithIncidents implements IEntity<string> {
|
||||||
|
readonly id: string;
|
||||||
|
readonly raceId: string;
|
||||||
|
readonly driverId: string;
|
||||||
|
readonly position: number;
|
||||||
|
readonly fastestLap: number;
|
||||||
|
readonly incidents: RaceIncidents;
|
||||||
|
readonly startPosition: number;
|
||||||
|
|
||||||
|
private constructor(props: {
|
||||||
|
id: string;
|
||||||
|
raceId: string;
|
||||||
|
driverId: string;
|
||||||
|
position: number;
|
||||||
|
fastestLap: number;
|
||||||
|
incidents: RaceIncidents;
|
||||||
|
startPosition: number;
|
||||||
|
}) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.raceId = props.raceId;
|
||||||
|
this.driverId = props.driverId;
|
||||||
|
this.position = props.position;
|
||||||
|
this.fastestLap = props.fastestLap;
|
||||||
|
this.incidents = props.incidents;
|
||||||
|
this.startPosition = props.startPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method to create a new Result entity
|
||||||
|
*/
|
||||||
|
static create(props: {
|
||||||
|
id: string;
|
||||||
|
raceId: string;
|
||||||
|
driverId: string;
|
||||||
|
position: number;
|
||||||
|
fastestLap: number;
|
||||||
|
incidents: RaceIncidents;
|
||||||
|
startPosition: number;
|
||||||
|
}): ResultWithIncidents {
|
||||||
|
ResultWithIncidents.validate(props);
|
||||||
|
return new ResultWithIncidents(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from legacy Result data (with incidents as number)
|
||||||
|
*/
|
||||||
|
static fromLegacy(props: {
|
||||||
|
id: string;
|
||||||
|
raceId: string;
|
||||||
|
driverId: string;
|
||||||
|
position: number;
|
||||||
|
fastestLap: number;
|
||||||
|
incidents: number;
|
||||||
|
startPosition: number;
|
||||||
|
}): ResultWithIncidents {
|
||||||
|
const raceIncidents = RaceIncidents.fromLegacyIncidentsCount(props.incidents);
|
||||||
|
return ResultWithIncidents.create({
|
||||||
|
...props,
|
||||||
|
incidents: raceIncidents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain validation logic
|
||||||
|
*/
|
||||||
|
private static validate(props: {
|
||||||
|
id: string;
|
||||||
|
raceId: string;
|
||||||
|
driverId: string;
|
||||||
|
position: number;
|
||||||
|
fastestLap: number;
|
||||||
|
incidents: RaceIncidents;
|
||||||
|
startPosition: number;
|
||||||
|
}): void {
|
||||||
|
if (!props.id || props.id.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('Result ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.raceId || props.raceId.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('Race ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('Driver ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(props.position) || props.position < 1) {
|
||||||
|
throw new RacingDomainValidationError('Position must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.fastestLap < 0) {
|
||||||
|
throw new RacingDomainValidationError('Fastest lap cannot be negative');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(props.startPosition) || props.startPosition < 1) {
|
||||||
|
throw new RacingDomainValidationError('Start position must be a positive integer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate positions gained/lost
|
||||||
|
*/
|
||||||
|
getPositionChange(): number {
|
||||||
|
return this.startPosition - this.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if driver finished on podium
|
||||||
|
*/
|
||||||
|
isPodium(): boolean {
|
||||||
|
return this.position <= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if driver had a clean race (no incidents)
|
||||||
|
*/
|
||||||
|
isClean(): boolean {
|
||||||
|
return this.incidents.isClean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total incident count (for backward compatibility)
|
||||||
|
*/
|
||||||
|
getTotalIncidents(): number {
|
||||||
|
return this.incidents.getTotalCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get incident severity score
|
||||||
|
*/
|
||||||
|
getIncidentSeverityScore(): number {
|
||||||
|
return this.incidents.getSeverityScore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable incident summary
|
||||||
|
*/
|
||||||
|
getIncidentSummary(): string {
|
||||||
|
return this.incidents.getSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an incident to this result
|
||||||
|
*/
|
||||||
|
addIncident(incident: IncidentRecord): ResultWithIncidents {
|
||||||
|
const updatedIncidents = this.incidents.addIncident(incident);
|
||||||
|
return new ResultWithIncidents({
|
||||||
|
...this,
|
||||||
|
incidents: updatedIncidents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to legacy format (for backward compatibility)
|
||||||
|
*/
|
||||||
|
toLegacyFormat() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
raceId: this.raceId,
|
||||||
|
driverId: this.driverId,
|
||||||
|
position: this.position,
|
||||||
|
fastestLap: this.fastestLap,
|
||||||
|
incidents: this.getTotalIncidents(),
|
||||||
|
startPosition: this.startPosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
311
packages/racing/domain/entities/Session.ts
Normal file
311
packages/racing/domain/entities/Session.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* Domain Entity: Session
|
||||||
|
*
|
||||||
|
* Represents a racing session within a race event.
|
||||||
|
* Immutable entity with factory methods and domain validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||||
|
import type { IEntity } from '@gridpilot/shared/domain';
|
||||||
|
import type { SessionType } from '../value-objects/SessionType';
|
||||||
|
|
||||||
|
export type SessionStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
export class Session implements IEntity<string> {
|
||||||
|
readonly id: string;
|
||||||
|
readonly raceEventId: string;
|
||||||
|
readonly scheduledAt: Date;
|
||||||
|
readonly track: string;
|
||||||
|
readonly trackId: string | undefined;
|
||||||
|
readonly car: string;
|
||||||
|
readonly carId: string | undefined;
|
||||||
|
readonly sessionType: SessionType;
|
||||||
|
readonly status: SessionStatus;
|
||||||
|
readonly strengthOfField: number | undefined;
|
||||||
|
readonly registeredCount: number | undefined;
|
||||||
|
readonly maxParticipants: number | undefined;
|
||||||
|
|
||||||
|
private constructor(props: {
|
||||||
|
id: string;
|
||||||
|
raceEventId: string;
|
||||||
|
scheduledAt: Date;
|
||||||
|
track: string;
|
||||||
|
trackId?: string;
|
||||||
|
car: string;
|
||||||
|
carId?: string;
|
||||||
|
sessionType: SessionType;
|
||||||
|
status: SessionStatus;
|
||||||
|
strengthOfField?: number;
|
||||||
|
registeredCount?: number;
|
||||||
|
maxParticipants?: number;
|
||||||
|
}) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.raceEventId = props.raceEventId;
|
||||||
|
this.scheduledAt = props.scheduledAt;
|
||||||
|
this.track = props.track;
|
||||||
|
this.trackId = props.trackId;
|
||||||
|
this.car = props.car;
|
||||||
|
this.carId = props.carId;
|
||||||
|
this.sessionType = props.sessionType;
|
||||||
|
this.status = props.status;
|
||||||
|
this.strengthOfField = props.strengthOfField;
|
||||||
|
this.registeredCount = props.registeredCount;
|
||||||
|
this.maxParticipants = props.maxParticipants;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method to create a new Session entity
|
||||||
|
*/
|
||||||
|
static create(props: {
|
||||||
|
id: string;
|
||||||
|
raceEventId: string;
|
||||||
|
scheduledAt: Date;
|
||||||
|
track: string;
|
||||||
|
trackId?: string;
|
||||||
|
car: string;
|
||||||
|
carId?: string;
|
||||||
|
sessionType: SessionType;
|
||||||
|
status?: SessionStatus;
|
||||||
|
strengthOfField?: number;
|
||||||
|
registeredCount?: number;
|
||||||
|
maxParticipants?: number;
|
||||||
|
}): Session {
|
||||||
|
this.validate(props);
|
||||||
|
|
||||||
|
return new Session({
|
||||||
|
id: props.id,
|
||||||
|
raceEventId: props.raceEventId,
|
||||||
|
scheduledAt: props.scheduledAt,
|
||||||
|
track: props.track,
|
||||||
|
...(props.trackId !== undefined ? { trackId: props.trackId } : {}),
|
||||||
|
car: props.car,
|
||||||
|
...(props.carId !== undefined ? { carId: props.carId } : {}),
|
||||||
|
sessionType: props.sessionType,
|
||||||
|
status: props.status ?? 'scheduled',
|
||||||
|
...(props.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}),
|
||||||
|
...(props.registeredCount !== undefined ? { registeredCount: props.registeredCount } : {}),
|
||||||
|
...(props.maxParticipants !== undefined ? { maxParticipants: props.maxParticipants } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain validation logic
|
||||||
|
*/
|
||||||
|
private static validate(props: {
|
||||||
|
id: string;
|
||||||
|
raceEventId: string;
|
||||||
|
scheduledAt: Date;
|
||||||
|
track: string;
|
||||||
|
car: string;
|
||||||
|
sessionType: SessionType;
|
||||||
|
}): void {
|
||||||
|
if (!props.id || props.id.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('Session ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.raceEventId || props.raceEventId.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('Race Event ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.scheduledAt || !(props.scheduledAt instanceof Date)) {
|
||||||
|
throw new RacingDomainValidationError('Valid scheduled date is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.track || props.track.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('Track is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.car || props.car.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('Car is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.sessionType) {
|
||||||
|
throw new RacingDomainValidationError('Session type is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the session (move from scheduled to running)
|
||||||
|
*/
|
||||||
|
start(): Session {
|
||||||
|
if (this.status !== 'scheduled') {
|
||||||
|
throw new RacingDomainInvariantError('Only scheduled sessions can be started');
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
id: this.id,
|
||||||
|
raceEventId: this.raceEventId,
|
||||||
|
scheduledAt: this.scheduledAt,
|
||||||
|
track: this.track,
|
||||||
|
car: this.car,
|
||||||
|
sessionType: this.sessionType,
|
||||||
|
status: 'running' as SessionStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
const withTrackId =
|
||||||
|
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
|
||||||
|
const withCarId =
|
||||||
|
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
|
||||||
|
const withSof =
|
||||||
|
this.strengthOfField !== undefined
|
||||||
|
? { ...withCarId, strengthOfField: this.strengthOfField }
|
||||||
|
: withCarId;
|
||||||
|
const withRegistered =
|
||||||
|
this.registeredCount !== undefined
|
||||||
|
? { ...withSof, registeredCount: this.registeredCount }
|
||||||
|
: withSof;
|
||||||
|
const props =
|
||||||
|
this.maxParticipants !== undefined
|
||||||
|
? { ...withRegistered, maxParticipants: this.maxParticipants }
|
||||||
|
: withRegistered;
|
||||||
|
|
||||||
|
return Session.create(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark session as completed
|
||||||
|
*/
|
||||||
|
complete(): Session {
|
||||||
|
if (this.status === 'completed') {
|
||||||
|
throw new RacingDomainInvariantError('Session is already completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.status === 'cancelled') {
|
||||||
|
throw new RacingDomainInvariantError('Cannot complete a cancelled session');
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
id: this.id,
|
||||||
|
raceEventId: this.raceEventId,
|
||||||
|
scheduledAt: this.scheduledAt,
|
||||||
|
track: this.track,
|
||||||
|
car: this.car,
|
||||||
|
sessionType: this.sessionType,
|
||||||
|
status: 'completed' as SessionStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
const withTrackId =
|
||||||
|
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
|
||||||
|
const withCarId =
|
||||||
|
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
|
||||||
|
const withSof =
|
||||||
|
this.strengthOfField !== undefined
|
||||||
|
? { ...withCarId, strengthOfField: this.strengthOfField }
|
||||||
|
: withCarId;
|
||||||
|
const withRegistered =
|
||||||
|
this.registeredCount !== undefined
|
||||||
|
? { ...withSof, registeredCount: this.registeredCount }
|
||||||
|
: withSof;
|
||||||
|
const props =
|
||||||
|
this.maxParticipants !== undefined
|
||||||
|
? { ...withRegistered, maxParticipants: this.maxParticipants }
|
||||||
|
: withRegistered;
|
||||||
|
|
||||||
|
return Session.create(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the session
|
||||||
|
*/
|
||||||
|
cancel(): Session {
|
||||||
|
if (this.status === 'completed') {
|
||||||
|
throw new RacingDomainInvariantError('Cannot cancel a completed session');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.status === 'cancelled') {
|
||||||
|
throw new RacingDomainInvariantError('Session is already cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
id: this.id,
|
||||||
|
raceEventId: this.raceEventId,
|
||||||
|
scheduledAt: this.scheduledAt,
|
||||||
|
track: this.track,
|
||||||
|
car: this.car,
|
||||||
|
sessionType: this.sessionType,
|
||||||
|
status: 'cancelled' as SessionStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
const withTrackId =
|
||||||
|
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
|
||||||
|
const withCarId =
|
||||||
|
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
|
||||||
|
const withSof =
|
||||||
|
this.strengthOfField !== undefined
|
||||||
|
? { ...withCarId, strengthOfField: this.strengthOfField }
|
||||||
|
: withCarId;
|
||||||
|
const withRegistered =
|
||||||
|
this.registeredCount !== undefined
|
||||||
|
? { ...withSof, registeredCount: this.registeredCount }
|
||||||
|
: withSof;
|
||||||
|
const props =
|
||||||
|
this.maxParticipants !== undefined
|
||||||
|
? { ...withRegistered, maxParticipants: this.maxParticipants }
|
||||||
|
: withRegistered;
|
||||||
|
|
||||||
|
return Session.create(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update SOF and participant count
|
||||||
|
*/
|
||||||
|
updateField(strengthOfField: number, registeredCount: number): Session {
|
||||||
|
const base = {
|
||||||
|
id: this.id,
|
||||||
|
raceEventId: this.raceEventId,
|
||||||
|
scheduledAt: this.scheduledAt,
|
||||||
|
track: this.track,
|
||||||
|
car: this.car,
|
||||||
|
sessionType: this.sessionType,
|
||||||
|
status: this.status,
|
||||||
|
strengthOfField,
|
||||||
|
registeredCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
const withTrackId =
|
||||||
|
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
|
||||||
|
const withCarId =
|
||||||
|
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
|
||||||
|
const props =
|
||||||
|
this.maxParticipants !== undefined
|
||||||
|
? { ...withCarId, maxParticipants: this.maxParticipants }
|
||||||
|
: withCarId;
|
||||||
|
|
||||||
|
return Session.create(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if session is in the past
|
||||||
|
*/
|
||||||
|
isPast(): boolean {
|
||||||
|
return this.scheduledAt < new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if session is upcoming
|
||||||
|
*/
|
||||||
|
isUpcoming(): boolean {
|
||||||
|
return this.status === 'scheduled' && !this.isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if session is live/running
|
||||||
|
*/
|
||||||
|
isLive(): boolean {
|
||||||
|
return this.status === 'running';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this session counts for championship points
|
||||||
|
*/
|
||||||
|
countsForPoints(): boolean {
|
||||||
|
return this.sessionType.countsForPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this session determines grid positions
|
||||||
|
*/
|
||||||
|
determinesGrid(): boolean {
|
||||||
|
return this.sessionType.determinesGrid();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/racing/domain/events/MainRaceCompleted.ts
Normal file
29
packages/racing/domain/events/MainRaceCompleted.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { IDomainEvent } from '@gridpilot/shared/domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain Event: MainRaceCompleted
|
||||||
|
*
|
||||||
|
* Fired when the main race session of a race event is completed.
|
||||||
|
* This triggers immediate performance summary notifications to drivers.
|
||||||
|
*/
|
||||||
|
export interface MainRaceCompletedEventData {
|
||||||
|
raceEventId: string;
|
||||||
|
sessionId: string;
|
||||||
|
leagueId: string;
|
||||||
|
seasonId: string;
|
||||||
|
completedAt: Date;
|
||||||
|
driverIds: string[]; // Drivers who participated in the main race
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MainRaceCompletedEvent implements IDomainEvent<MainRaceCompletedEventData> {
|
||||||
|
readonly eventType = 'MainRaceCompleted';
|
||||||
|
readonly aggregateId: string;
|
||||||
|
readonly eventData: MainRaceCompletedEventData;
|
||||||
|
readonly occurredAt: Date;
|
||||||
|
|
||||||
|
constructor(data: MainRaceCompletedEventData) {
|
||||||
|
this.aggregateId = data.raceEventId;
|
||||||
|
this.eventData = { ...data };
|
||||||
|
this.occurredAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/racing/domain/events/RaceEventStewardingClosed.ts
Normal file
29
packages/racing/domain/events/RaceEventStewardingClosed.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { IDomainEvent } from '@gridpilot/shared/domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain Event: RaceEventStewardingClosed
|
||||||
|
*
|
||||||
|
* Fired when the stewarding window closes for a race event.
|
||||||
|
* This triggers final results notifications to drivers with any penalty adjustments.
|
||||||
|
*/
|
||||||
|
export interface RaceEventStewardingClosedEventData {
|
||||||
|
raceEventId: string;
|
||||||
|
leagueId: string;
|
||||||
|
seasonId: string;
|
||||||
|
closedAt: Date;
|
||||||
|
driverIds: string[]; // Drivers who participated in the race event
|
||||||
|
hadPenaltiesApplied: boolean; // Whether any penalties were applied during stewarding
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RaceEventStewardingClosedEvent implements IDomainEvent<RaceEventStewardingClosedEventData> {
|
||||||
|
readonly eventType = 'RaceEventStewardingClosed';
|
||||||
|
readonly aggregateId: string;
|
||||||
|
readonly eventData: RaceEventStewardingClosedEventData;
|
||||||
|
readonly occurredAt: Date;
|
||||||
|
|
||||||
|
constructor(data: RaceEventStewardingClosedEventData) {
|
||||||
|
this.aggregateId = data.raceEventId;
|
||||||
|
this.eventData = { ...data };
|
||||||
|
this.occurredAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/racing/domain/repositories/IRaceEventRepository.ts
Normal file
14
packages/racing/domain/repositories/IRaceEventRepository.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { RaceEvent } from '../entities/RaceEvent';
|
||||||
|
|
||||||
|
export interface IRaceEventRepository {
|
||||||
|
findById(id: string): Promise<RaceEvent | null>;
|
||||||
|
findAll(): Promise<RaceEvent[]>;
|
||||||
|
findBySeasonId(seasonId: string): Promise<RaceEvent[]>;
|
||||||
|
findByLeagueId(leagueId: string): Promise<RaceEvent[]>;
|
||||||
|
findByStatus(status: string): Promise<RaceEvent[]>;
|
||||||
|
findAwaitingStewardingClose(): Promise<RaceEvent[]>;
|
||||||
|
create(raceEvent: RaceEvent): Promise<RaceEvent>;
|
||||||
|
update(raceEvent: RaceEvent): Promise<RaceEvent>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
exists(id: string): Promise<boolean>;
|
||||||
|
}
|
||||||
13
packages/racing/domain/repositories/ISessionRepository.ts
Normal file
13
packages/racing/domain/repositories/ISessionRepository.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Session } from '../entities/Session';
|
||||||
|
|
||||||
|
export interface ISessionRepository {
|
||||||
|
findById(id: string): Promise<Session | null>;
|
||||||
|
findAll(): Promise<Session[]>;
|
||||||
|
findByRaceEventId(raceEventId: string): Promise<Session[]>;
|
||||||
|
findByLeagueId(leagueId: string): Promise<Session[]>;
|
||||||
|
findByStatus(status: string): Promise<Session[]>;
|
||||||
|
create(session: Session): Promise<Session>;
|
||||||
|
update(session: Session): Promise<Session>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
exists(id: string): Promise<boolean>;
|
||||||
|
}
|
||||||
239
packages/racing/domain/value-objects/RaceIncidents.ts
Normal file
239
packages/racing/domain/value-objects/RaceIncidents.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incident types that can occur during a race
|
||||||
|
*/
|
||||||
|
export type IncidentType =
|
||||||
|
| 'track_limits' // Driver went off track and gained advantage
|
||||||
|
| 'contact' // Physical contact with another car
|
||||||
|
| 'unsafe_rejoin' // Unsafe rejoining of the track
|
||||||
|
| 'aggressive_driving' // Aggressive defensive or overtaking maneuvers
|
||||||
|
| 'false_start' // Started before green flag
|
||||||
|
| 'collision' // Major collision involving multiple cars
|
||||||
|
| 'spin' // Driver spun out
|
||||||
|
| 'mechanical' // Mechanical failure (not driver error)
|
||||||
|
| 'other'; // Other incident types
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual incident record
|
||||||
|
*/
|
||||||
|
export interface IncidentRecord {
|
||||||
|
type: IncidentType;
|
||||||
|
lap: number;
|
||||||
|
description?: string;
|
||||||
|
penaltyPoints?: number; // Points deducted for this incident
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object: RaceIncidents
|
||||||
|
*
|
||||||
|
* Encapsulates all incidents that occurred during a driver's race.
|
||||||
|
* Provides methods to calculate total penalty points and incident severity.
|
||||||
|
*/
|
||||||
|
export class RaceIncidents implements IValueObject<IncidentRecord[]> {
|
||||||
|
private readonly incidents: IncidentRecord[];
|
||||||
|
|
||||||
|
constructor(incidents: IncidentRecord[] = []) {
|
||||||
|
this.incidents = [...incidents];
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): IncidentRecord[] {
|
||||||
|
return [...this.incidents];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new incident
|
||||||
|
*/
|
||||||
|
addIncident(incident: IncidentRecord): RaceIncidents {
|
||||||
|
return new RaceIncidents([...this.incidents, incident]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all incidents
|
||||||
|
*/
|
||||||
|
getAllIncidents(): IncidentRecord[] {
|
||||||
|
return [...this.incidents];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total number of incidents
|
||||||
|
*/
|
||||||
|
getTotalCount(): number {
|
||||||
|
return this.incidents.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total penalty points from all incidents
|
||||||
|
*/
|
||||||
|
getTotalPenaltyPoints(): number {
|
||||||
|
return this.incidents.reduce((total, incident) => total + (incident.penaltyPoints || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get incidents by type
|
||||||
|
*/
|
||||||
|
getIncidentsByType(type: IncidentType): IncidentRecord[] {
|
||||||
|
return this.incidents.filter(incident => incident.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if driver had any incidents
|
||||||
|
*/
|
||||||
|
hasIncidents(): boolean {
|
||||||
|
return this.incidents.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if driver had a clean race (no incidents)
|
||||||
|
*/
|
||||||
|
isClean(): boolean {
|
||||||
|
return this.incidents.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get incident severity score (0-100, higher = more severe)
|
||||||
|
*/
|
||||||
|
getSeverityScore(): number {
|
||||||
|
if (this.incidents.length === 0) return 0;
|
||||||
|
|
||||||
|
const severityWeights: Record<IncidentType, number> = {
|
||||||
|
track_limits: 10,
|
||||||
|
contact: 20,
|
||||||
|
unsafe_rejoin: 25,
|
||||||
|
aggressive_driving: 15,
|
||||||
|
false_start: 30,
|
||||||
|
collision: 40,
|
||||||
|
spin: 35,
|
||||||
|
mechanical: 5, // Lower weight as it's not driver error
|
||||||
|
other: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalSeverity = this.incidents.reduce((total, incident) => {
|
||||||
|
return total + severityWeights[incident.type];
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Normalize to 0-100 scale (cap at 100 for very incident-heavy races)
|
||||||
|
return Math.min(100, totalSeverity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable incident summary
|
||||||
|
*/
|
||||||
|
getSummary(): string {
|
||||||
|
if (this.incidents.length === 0) {
|
||||||
|
return 'Clean race';
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeCounts = this.incidents.reduce((counts, incident) => {
|
||||||
|
counts[incident.type] = (counts[incident.type] || 0) + 1;
|
||||||
|
return counts;
|
||||||
|
}, {} as Record<IncidentType, number>);
|
||||||
|
|
||||||
|
const summaryParts = Object.entries(typeCounts).map(([type, count]) => {
|
||||||
|
const typeLabel = this.getIncidentTypeLabel(type as IncidentType);
|
||||||
|
return count > 1 ? `${count}x ${typeLabel}` : typeLabel;
|
||||||
|
});
|
||||||
|
|
||||||
|
return summaryParts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable label for incident type
|
||||||
|
*/
|
||||||
|
private getIncidentTypeLabel(type: IncidentType): string {
|
||||||
|
const labels: Record<IncidentType, string> = {
|
||||||
|
track_limits: 'Track Limits',
|
||||||
|
contact: 'Contact',
|
||||||
|
unsafe_rejoin: 'Unsafe Rejoin',
|
||||||
|
aggressive_driving: 'Aggressive Driving',
|
||||||
|
false_start: 'False Start',
|
||||||
|
collision: 'Collision',
|
||||||
|
spin: 'Spin',
|
||||||
|
mechanical: 'Mechanical',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
return labels[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<IncidentRecord[]>): boolean {
|
||||||
|
const otherIncidents = other.props;
|
||||||
|
if (this.incidents.length !== otherIncidents.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort both arrays and compare
|
||||||
|
const sortedThis = [...this.incidents].sort((a, b) => a.lap - b.lap);
|
||||||
|
const sortedOther = [...otherIncidents].sort((a, b) => a.lap - b.lap);
|
||||||
|
|
||||||
|
return sortedThis.every((incident, index) => {
|
||||||
|
const otherIncident = sortedOther[index];
|
||||||
|
return incident.type === otherIncident.type &&
|
||||||
|
incident.lap === otherIncident.lap &&
|
||||||
|
incident.description === otherIncident.description &&
|
||||||
|
incident.penaltyPoints === otherIncident.penaltyPoints;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create RaceIncidents from legacy incidents count
|
||||||
|
*/
|
||||||
|
static fromLegacyIncidentsCount(count: number): RaceIncidents {
|
||||||
|
if (count === 0) {
|
||||||
|
return new RaceIncidents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribute legacy incidents across different types based on probability
|
||||||
|
const incidents: IncidentRecord[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const type = RaceIncidents.getRandomIncidentType();
|
||||||
|
incidents.push({
|
||||||
|
type,
|
||||||
|
lap: Math.floor(Math.random() * 20) + 1, // Random lap 1-20
|
||||||
|
penaltyPoints: RaceIncidents.getDefaultPenaltyPoints(type),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RaceIncidents(incidents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get random incident type for legacy data conversion
|
||||||
|
*/
|
||||||
|
private static getRandomIncidentType(): IncidentType {
|
||||||
|
const types: IncidentType[] = [
|
||||||
|
'track_limits', 'contact', 'unsafe_rejoin', 'aggressive_driving',
|
||||||
|
'collision', 'spin', 'other'
|
||||||
|
];
|
||||||
|
const weights = [0.4, 0.25, 0.15, 0.1, 0.05, 0.04, 0.01]; // Probability weights
|
||||||
|
|
||||||
|
const random = Math.random();
|
||||||
|
let cumulativeWeight = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < types.length; i++) {
|
||||||
|
cumulativeWeight += weights[i];
|
||||||
|
if (random <= cumulativeWeight) {
|
||||||
|
return types[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default penalty points for incident type
|
||||||
|
*/
|
||||||
|
private static getDefaultPenaltyPoints(type: IncidentType): number {
|
||||||
|
const penalties: Record<IncidentType, number> = {
|
||||||
|
track_limits: 0, // Usually just a warning
|
||||||
|
contact: 2,
|
||||||
|
unsafe_rejoin: 3,
|
||||||
|
aggressive_driving: 2,
|
||||||
|
false_start: 5,
|
||||||
|
collision: 5,
|
||||||
|
spin: 0, // Usually no penalty if no contact
|
||||||
|
mechanical: 0,
|
||||||
|
other: 2,
|
||||||
|
};
|
||||||
|
return penalties[type];
|
||||||
|
}
|
||||||
|
}
|
||||||
103
packages/racing/domain/value-objects/SessionType.ts
Normal file
103
packages/racing/domain/value-objects/SessionType.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object: SessionType
|
||||||
|
*
|
||||||
|
* Represents the type of racing session within a race event.
|
||||||
|
* Immutable value object with domain validation.
|
||||||
|
*/
|
||||||
|
export type SessionTypeValue = 'practice' | 'qualifying' | 'q1' | 'q2' | 'q3' | 'sprint' | 'main' | 'timeTrial';
|
||||||
|
|
||||||
|
export class SessionType implements IValueObject<SessionTypeValue> {
|
||||||
|
readonly value: SessionTypeValue;
|
||||||
|
|
||||||
|
constructor(value: SessionTypeValue) {
|
||||||
|
if (!value || !this.isValidSessionType(value)) {
|
||||||
|
throw new RacingDomainValidationError(`Invalid session type: ${value}`);
|
||||||
|
}
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isValidSessionType(value: string): value is SessionTypeValue {
|
||||||
|
const validTypes: SessionTypeValue[] = ['practice', 'qualifying', 'q1', 'q2', 'q3', 'sprint', 'main', 'timeTrial'];
|
||||||
|
return validTypes.includes(value as SessionTypeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): SessionTypeValue {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<SessionTypeValue>): boolean {
|
||||||
|
return this.value === other.props;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this session type counts for championship points
|
||||||
|
*/
|
||||||
|
countsForPoints(): boolean {
|
||||||
|
return this.value === 'main' || this.value === 'sprint';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this session type determines grid positions
|
||||||
|
*/
|
||||||
|
determinesGrid(): boolean {
|
||||||
|
return this.value === 'qualifying' || this.value.startsWith('q');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable display name
|
||||||
|
*/
|
||||||
|
getDisplayName(): string {
|
||||||
|
const names: Record<SessionTypeValue, string> = {
|
||||||
|
practice: 'Practice',
|
||||||
|
qualifying: 'Qualifying',
|
||||||
|
q1: 'Q1',
|
||||||
|
q2: 'Q2',
|
||||||
|
q3: 'Q3',
|
||||||
|
sprint: 'Sprint Race',
|
||||||
|
main: 'Main Race',
|
||||||
|
timeTrial: 'Time Trial',
|
||||||
|
};
|
||||||
|
return names[this.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get short display name for UI
|
||||||
|
*/
|
||||||
|
getShortName(): string {
|
||||||
|
const names: Record<SessionTypeValue, string> = {
|
||||||
|
practice: 'P',
|
||||||
|
qualifying: 'Q',
|
||||||
|
q1: 'Q1',
|
||||||
|
q2: 'Q2',
|
||||||
|
q3: 'Q3',
|
||||||
|
sprint: 'SPR',
|
||||||
|
main: 'RACE',
|
||||||
|
timeTrial: 'TT',
|
||||||
|
};
|
||||||
|
return names[this.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static factory methods for common types
|
||||||
|
static practice(): SessionType {
|
||||||
|
return new SessionType('practice');
|
||||||
|
}
|
||||||
|
|
||||||
|
static qualifying(): SessionType {
|
||||||
|
return new SessionType('qualifying');
|
||||||
|
}
|
||||||
|
|
||||||
|
static sprint(): SessionType {
|
||||||
|
return new SessionType('sprint');
|
||||||
|
}
|
||||||
|
|
||||||
|
static main(): SessionType {
|
||||||
|
return new SessionType('main');
|
||||||
|
}
|
||||||
|
|
||||||
|
static timeTrial(): SessionType {
|
||||||
|
return new SessionType('timeTrial');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* In-memory implementation of IRaceEventRepository for development/testing.
|
||||||
|
*/
|
||||||
|
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||||
|
import type { RaceEvent } from '../../domain/entities/RaceEvent';
|
||||||
|
|
||||||
|
export class InMemoryRaceEventRepository implements IRaceEventRepository {
|
||||||
|
private raceEvents: Map<string, RaceEvent> = new Map();
|
||||||
|
|
||||||
|
async findById(id: string): Promise<RaceEvent | null> {
|
||||||
|
return this.raceEvents.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<RaceEvent[]> {
|
||||||
|
return Array.from(this.raceEvents.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBySeasonId(seasonId: string): Promise<RaceEvent[]> {
|
||||||
|
return Array.from(this.raceEvents.values()).filter(
|
||||||
|
raceEvent => raceEvent.seasonId === seasonId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByLeagueId(leagueId: string): Promise<RaceEvent[]> {
|
||||||
|
return Array.from(this.raceEvents.values()).filter(
|
||||||
|
raceEvent => raceEvent.leagueId === leagueId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByStatus(status: string): Promise<RaceEvent[]> {
|
||||||
|
return Array.from(this.raceEvents.values()).filter(
|
||||||
|
raceEvent => raceEvent.status === status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAwaitingStewardingClose(): Promise<RaceEvent[]> {
|
||||||
|
const now = new Date();
|
||||||
|
return Array.from(this.raceEvents.values()).filter(
|
||||||
|
raceEvent =>
|
||||||
|
raceEvent.status === 'awaiting_stewarding' &&
|
||||||
|
raceEvent.stewardingClosesAt &&
|
||||||
|
raceEvent.stewardingClosesAt <= now
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(raceEvent: RaceEvent): Promise<RaceEvent> {
|
||||||
|
this.raceEvents.set(raceEvent.id, raceEvent);
|
||||||
|
return raceEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(raceEvent: RaceEvent): Promise<RaceEvent> {
|
||||||
|
this.raceEvents.set(raceEvent.id, raceEvent);
|
||||||
|
return raceEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
this.raceEvents.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(id: string): Promise<boolean> {
|
||||||
|
return this.raceEvents.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test helper methods
|
||||||
|
clear(): void {
|
||||||
|
this.raceEvents.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): RaceEvent[] {
|
||||||
|
return Array.from(this.raceEvents.values());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* In-memory implementation of ISessionRepository for development/testing.
|
||||||
|
*/
|
||||||
|
import type { ISessionRepository } from '../../domain/repositories/ISessionRepository';
|
||||||
|
import type { Session } from '../../domain/entities/Session';
|
||||||
|
|
||||||
|
export class InMemorySessionRepository implements ISessionRepository {
|
||||||
|
private sessions: Map<string, Session> = new Map();
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Session | null> {
|
||||||
|
return this.sessions.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<Session[]> {
|
||||||
|
return Array.from(this.sessions.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByRaceEventId(raceEventId: string): Promise<Session[]> {
|
||||||
|
return Array.from(this.sessions.values()).filter(
|
||||||
|
session => session.raceEventId === raceEventId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByLeagueId(leagueId: string): Promise<Session[]> {
|
||||||
|
// Sessions don't have leagueId directly - would need to join with RaceEvent
|
||||||
|
// For now, return empty array
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByStatus(status: string): Promise<Session[]> {
|
||||||
|
return Array.from(this.sessions.values()).filter(
|
||||||
|
session => session.status === status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(session: Session): Promise<Session> {
|
||||||
|
this.sessions.set(session.id, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(session: Session): Promise<Session> {
|
||||||
|
this.sessions.set(session.id, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
this.sessions.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(id: string): Promise<boolean> {
|
||||||
|
return this.sessions.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test helper methods
|
||||||
|
clear(): void {
|
||||||
|
this.sessions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): Session[] {
|
||||||
|
return Array.from(this.sessions.values());
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/shared/domain/IDomainEvent.ts
Normal file
10
packages/shared/domain/IDomainEvent.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface IDomainEvent<T = any> {
|
||||||
|
readonly eventType: string;
|
||||||
|
readonly aggregateId: string;
|
||||||
|
readonly eventData: T;
|
||||||
|
readonly occurredAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDomainEventPublisher {
|
||||||
|
publish(event: IDomainEvent): Promise<void>;
|
||||||
|
}
|
||||||
@@ -96,7 +96,8 @@ export function createLeagues(ownerIds: string[]): League[] {
|
|||||||
for (let i = 0; i < leagueCount; i++) {
|
for (let i = 0; i < leagueCount; i++) {
|
||||||
const id = `league-${i + 1}`;
|
const id = `league-${i + 1}`;
|
||||||
const name = leagueNames[i] ?? faker.company.name();
|
const name = leagueNames[i] ?? faker.company.name();
|
||||||
const ownerId = pickOne(ownerIds);
|
// Ensure league-5 (demo league with running race) is owned by driver-1
|
||||||
|
const ownerId = i === 4 ? 'driver-1' : pickOne(ownerIds);
|
||||||
|
|
||||||
const maxDriversOptions = [24, 32, 48, 64];
|
const maxDriversOptions = [24, 32, 48, 64];
|
||||||
let settings = {
|
let settings = {
|
||||||
@@ -209,6 +210,7 @@ export function createMemberships(
|
|||||||
teamsByLeague.set(team.primaryLeagueId, list);
|
teamsByLeague.set(team.primaryLeagueId, list);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
drivers.forEach((driver) => {
|
drivers.forEach((driver) => {
|
||||||
// Each driver participates in 1–3 leagues
|
// Each driver participates in 1–3 leagues
|
||||||
const leagueSampleSize = faker.number.int({ min: 1, max: Math.min(3, leagues.length) });
|
const leagueSampleSize = faker.number.int({ min: 1, max: Math.min(3, leagues.length) });
|
||||||
@@ -264,10 +266,24 @@ export function createRaces(leagues: League[]): Race[] {
|
|||||||
|
|
||||||
for (let i = 0; i < raceCount; i++) {
|
for (let i = 0; i < raceCount; i++) {
|
||||||
const id = `race-${i + 1}`;
|
const id = `race-${i + 1}`;
|
||||||
const league = pickOne(leagues);
|
let league = pickOne(leagues);
|
||||||
const offsetDays = faker.number.int({ min: -30, max: 45 });
|
const offsetDays = faker.number.int({ min: -30, max: 45 });
|
||||||
const scheduledAt = new Date(baseDate.getTime() + offsetDays * 24 * 60 * 60 * 1000);
|
const scheduledAt = new Date(baseDate.getTime() + offsetDays * 24 * 60 * 60 * 1000);
|
||||||
const status = scheduledAt.getTime() < baseDate.getTime() ? 'completed' : 'scheduled';
|
let status: 'scheduled' | 'completed' | 'running' = scheduledAt.getTime() < baseDate.getTime() ? 'completed' : 'scheduled';
|
||||||
|
let strengthOfField: number | undefined;
|
||||||
|
|
||||||
|
// Special case: Make race-1 a running race in league-5 (user's admin league)
|
||||||
|
if (i === 0) {
|
||||||
|
const league5 = leagues.find(l => l.id === 'league-5');
|
||||||
|
if (league5) {
|
||||||
|
league = league5;
|
||||||
|
status = 'running';
|
||||||
|
// Calculate SOF for the running race (simulate 12-20 drivers with average rating ~1500)
|
||||||
|
const participantCount = faker.number.int({ min: 12, max: 20 });
|
||||||
|
const averageRating = 1500 + faker.number.int({ min: -200, max: 300 });
|
||||||
|
strengthOfField = Math.round(averageRating);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
races.push(
|
races.push(
|
||||||
Race.create({
|
Race.create({
|
||||||
@@ -278,6 +294,8 @@ export function createRaces(leagues: League[]): Race[] {
|
|||||||
car: faker.helpers.arrayElement(cars),
|
car: faker.helpers.arrayElement(cars),
|
||||||
sessionType: 'race',
|
sessionType: 'race',
|
||||||
status,
|
status,
|
||||||
|
...(strengthOfField !== undefined ? { strengthOfField } : {}),
|
||||||
|
...(status === 'running' ? { registeredCount: faker.number.int({ min: 12, max: 20 }) } : {}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
99
tests/bdd/race-event-performance-summary.feature
Normal file
99
tests/bdd/race-event-performance-summary.feature
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
Feature: Race Event Performance Summary Notifications
|
||||||
|
|
||||||
|
As a driver
|
||||||
|
I want to receive performance summary notifications after races
|
||||||
|
So that I can see my results and rating changes immediately
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given a league exists with stewarding configuration
|
||||||
|
And a season exists for that league
|
||||||
|
And a race event is scheduled with practice, qualifying, and main race sessions
|
||||||
|
|
||||||
|
Scenario: Driver receives performance summary after main race completion
|
||||||
|
Given I am a registered driver for the race event
|
||||||
|
And all sessions are scheduled
|
||||||
|
When the main race session is completed
|
||||||
|
Then a MainRaceCompleted domain event is published
|
||||||
|
And I receive a race_performance_summary notification
|
||||||
|
And the notification shows my position, incidents, and provisional rating change
|
||||||
|
And the notification has modal urgency and requires no response
|
||||||
|
|
||||||
|
Scenario: Driver receives final results after stewarding closes
|
||||||
|
Given I am a registered driver for the race event
|
||||||
|
And the main race has been completed
|
||||||
|
And the race event is in awaiting_stewarding status
|
||||||
|
When the stewarding window expires
|
||||||
|
Then a RaceEventStewardingClosed domain event is published
|
||||||
|
And I receive a race_final_results notification
|
||||||
|
And the notification shows my final position and rating change
|
||||||
|
And the notification indicates if penalties were applied
|
||||||
|
|
||||||
|
Scenario: Practice and qualifying sessions don't trigger notifications
|
||||||
|
Given I am a registered driver for the race event
|
||||||
|
When practice and qualifying sessions are completed
|
||||||
|
Then no performance summary notifications are sent
|
||||||
|
And the race event status remains in_progress
|
||||||
|
|
||||||
|
Scenario: Only main race completion triggers performance summary
|
||||||
|
Given I am a registered driver for the race event
|
||||||
|
And the race event has practice, qualifying, sprint, and main race sessions
|
||||||
|
When the sprint race session is completed
|
||||||
|
Then no performance summary notification is sent
|
||||||
|
When the main race session is completed
|
||||||
|
Then a performance summary notification is sent
|
||||||
|
|
||||||
|
Scenario: Provisional rating changes are calculated correctly
|
||||||
|
Given I finished in position 1 with 0 incidents
|
||||||
|
When the main race is completed
|
||||||
|
Then my provisional rating change should be +25 points
|
||||||
|
And the notification should display "+25 rating"
|
||||||
|
|
||||||
|
Scenario: Rating penalties are applied for incidents
|
||||||
|
Given I finished in position 5 with 3 incidents
|
||||||
|
When the main race is completed
|
||||||
|
Then my provisional rating change should be reduced by 15 points
|
||||||
|
And the notification should show the adjusted rating change
|
||||||
|
|
||||||
|
Scenario: DNF results show appropriate rating penalty
|
||||||
|
Given I did not finish the race (DNF)
|
||||||
|
When the main race is completed
|
||||||
|
Then my provisional rating change should be -10 points
|
||||||
|
And the notification should display "DNF" as position
|
||||||
|
|
||||||
|
Scenario: Stewarding close mechanism works correctly
|
||||||
|
Given a race event is awaiting_stewarding
|
||||||
|
And the stewarding window is configured for 24 hours
|
||||||
|
When 24 hours have passed since the main race completion
|
||||||
|
Then the CloseRaceEventStewardingUseCase should close the event
|
||||||
|
And final results notifications should be sent to all participants
|
||||||
|
|
||||||
|
Scenario: Race event lifecycle transitions work correctly
|
||||||
|
Given a race event is scheduled
|
||||||
|
When practice and qualifying sessions start
|
||||||
|
Then the race event status becomes in_progress
|
||||||
|
When the main race completes
|
||||||
|
Then the race event status becomes awaiting_stewarding
|
||||||
|
When stewarding closes
|
||||||
|
Then the race event status becomes closed
|
||||||
|
|
||||||
|
Scenario: Notifications include proper action buttons
|
||||||
|
Given I receive a performance summary notification
|
||||||
|
Then it should have a "View Full Results" action button
|
||||||
|
And clicking it should navigate to the race results page
|
||||||
|
|
||||||
|
Scenario: Final results notifications include championship standings link
|
||||||
|
Given I receive a final results notification
|
||||||
|
Then it should have a "View Championship Standings" action button
|
||||||
|
And clicking it should navigate to the league standings page
|
||||||
|
|
||||||
|
Scenario: Notifications are sent to all registered drivers
|
||||||
|
Given 10 drivers are registered for the race event
|
||||||
|
When the main race is completed
|
||||||
|
Then 10 performance summary notifications should be sent
|
||||||
|
When stewarding closes
|
||||||
|
Then 10 final results notifications should be sent
|
||||||
|
|
||||||
|
Scenario: League configuration affects stewarding window
|
||||||
|
Given a league has stewardingClosesHours set to 48
|
||||||
|
When a race event is created for that league
|
||||||
|
Then the stewarding window should be 48 hours after main race completion
|
||||||
284
tests/bdd/race-event-performance-summary.test.ts
Normal file
284
tests/bdd/race-event-performance-summary.test.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||||
|
import { Session } from '../../packages/racing/domain/entities/Session';
|
||||||
|
import { RaceEvent } from '../../packages/racing/domain/entities/RaceEvent';
|
||||||
|
import { SessionType } from '../../packages/racing/domain/value-objects/SessionType';
|
||||||
|
import { MainRaceCompletedEvent } from '../../packages/racing/domain/events/MainRaceCompleted';
|
||||||
|
import { RaceEventStewardingClosedEvent } from '../../packages/racing/domain/events/RaceEventStewardingClosed';
|
||||||
|
import { SendPerformanceSummaryUseCase } from '../../packages/racing/application/use-cases/SendPerformanceSummaryUseCase';
|
||||||
|
import { SendFinalResultsUseCase } from '../../packages/racing/application/use-cases/SendFinalResultsUseCase';
|
||||||
|
import { CloseRaceEventStewardingUseCase } from '../../packages/racing/application/use-cases/CloseRaceEventStewardingUseCase';
|
||||||
|
import { InMemoryRaceEventRepository } from '../../packages/racing/infrastructure/repositories/InMemoryRaceEventRepository';
|
||||||
|
import { InMemorySessionRepository } from '../../packages/racing/infrastructure/repositories/InMemorySessionRepository';
|
||||||
|
|
||||||
|
// Mock notification service
|
||||||
|
const mockNotificationService = {
|
||||||
|
sendNotification: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test data builders
|
||||||
|
const createTestSession = (overrides: Partial<{
|
||||||
|
id: string;
|
||||||
|
raceEventId: string;
|
||||||
|
sessionType: SessionType;
|
||||||
|
status: 'scheduled' | 'running' | 'completed';
|
||||||
|
scheduledAt: Date;
|
||||||
|
}> = {}) => {
|
||||||
|
return Session.create({
|
||||||
|
id: overrides.id ?? 'session-1',
|
||||||
|
raceEventId: overrides.raceEventId ?? 'race-event-1',
|
||||||
|
scheduledAt: overrides.scheduledAt ?? new Date(),
|
||||||
|
track: 'Monza',
|
||||||
|
car: 'F1 Car',
|
||||||
|
sessionType: overrides.sessionType ?? SessionType.main(),
|
||||||
|
status: overrides.status ?? 'scheduled',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestRaceEvent = (overrides: Partial<{
|
||||||
|
id: string;
|
||||||
|
seasonId: string;
|
||||||
|
leagueId: string;
|
||||||
|
name: string;
|
||||||
|
sessions: Session[];
|
||||||
|
status: 'scheduled' | 'in_progress' | 'awaiting_stewarding' | 'closed';
|
||||||
|
stewardingClosesAt: Date;
|
||||||
|
}> = {}) => {
|
||||||
|
const sessions = overrides.sessions ?? [
|
||||||
|
createTestSession({ id: 'practice-1', sessionType: SessionType.practice() }),
|
||||||
|
createTestSession({ id: 'qualifying-1', sessionType: SessionType.qualifying() }),
|
||||||
|
createTestSession({ id: 'main-1', sessionType: SessionType.main() }),
|
||||||
|
];
|
||||||
|
|
||||||
|
return RaceEvent.create({
|
||||||
|
id: overrides.id ?? 'race-event-1',
|
||||||
|
seasonId: overrides.seasonId ?? 'season-1',
|
||||||
|
leagueId: overrides.leagueId ?? 'league-1',
|
||||||
|
name: overrides.name ?? 'Monza Grand Prix',
|
||||||
|
sessions,
|
||||||
|
status: overrides.status ?? 'scheduled',
|
||||||
|
stewardingClosesAt: overrides.stewardingClosesAt,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Race Event Performance Summary Notifications', () => {
|
||||||
|
let raceEventRepository: InMemoryRaceEventRepository;
|
||||||
|
let sessionRepository: InMemorySessionRepository;
|
||||||
|
let sendPerformanceSummaryUseCase: SendPerformanceSummaryUseCase;
|
||||||
|
let sendFinalResultsUseCase: SendFinalResultsUseCase;
|
||||||
|
let closeStewardingUseCase: CloseRaceEventStewardingUseCase;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
raceEventRepository = new InMemoryRaceEventRepository();
|
||||||
|
sessionRepository = new InMemorySessionRepository();
|
||||||
|
sendPerformanceSummaryUseCase = new SendPerformanceSummaryUseCase(
|
||||||
|
mockNotificationService as any,
|
||||||
|
raceEventRepository as any,
|
||||||
|
{} as any // Mock result repository
|
||||||
|
);
|
||||||
|
sendFinalResultsUseCase = new SendFinalResultsUseCase(
|
||||||
|
mockNotificationService as any,
|
||||||
|
raceEventRepository as any,
|
||||||
|
{} as any // Mock result repository
|
||||||
|
);
|
||||||
|
closeStewardingUseCase = new CloseRaceEventStewardingUseCase(
|
||||||
|
raceEventRepository as any,
|
||||||
|
{} as any // Mock domain event publisher
|
||||||
|
);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Performance Summary After Main Race Completion', () => {
|
||||||
|
it('should send performance summary notification when main race completes', async () => {
|
||||||
|
// Given
|
||||||
|
const raceEvent = createTestRaceEvent();
|
||||||
|
await raceEventRepository.create(raceEvent);
|
||||||
|
|
||||||
|
const mainRaceCompletedEvent = new MainRaceCompletedEvent({
|
||||||
|
raceEventId: raceEvent.id,
|
||||||
|
sessionId: 'main-1',
|
||||||
|
leagueId: raceEvent.leagueId,
|
||||||
|
seasonId: raceEvent.seasonId,
|
||||||
|
completedAt: new Date(),
|
||||||
|
driverIds: ['driver-1', 'driver-2'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// When
|
||||||
|
await sendPerformanceSummaryUseCase.execute(mainRaceCompletedEvent);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(mockNotificationService.sendNotification).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
recipientId: 'driver-1',
|
||||||
|
type: 'race_performance_summary',
|
||||||
|
urgency: 'modal',
|
||||||
|
title: expect.stringContaining('Race Complete'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate provisional rating changes correctly', async () => {
|
||||||
|
// Given
|
||||||
|
const raceEvent = createTestRaceEvent();
|
||||||
|
await raceEventRepository.create(raceEvent);
|
||||||
|
|
||||||
|
// Mock result repository to return position data
|
||||||
|
const mockResultRepository = {
|
||||||
|
findByRaceId: vi.fn().mockResolvedValue([
|
||||||
|
{ driverId: 'driver-1', position: 1, incidents: 0, getPositionChange: () => 0 },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const useCase = new SendPerformanceSummaryUseCase(
|
||||||
|
mockNotificationService as any,
|
||||||
|
raceEventRepository as any,
|
||||||
|
mockResultRepository as any
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = new MainRaceCompletedEvent({
|
||||||
|
raceEventId: raceEvent.id,
|
||||||
|
sessionId: 'main-1',
|
||||||
|
leagueId: raceEvent.leagueId,
|
||||||
|
seasonId: raceEvent.seasonId,
|
||||||
|
completedAt: new Date(),
|
||||||
|
driverIds: ['driver-1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// When
|
||||||
|
await useCase.execute(event);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
provisionalRatingChange: 25, // P1 with 0 incidents = +25
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Final Results After Stewarding Closes', () => {
|
||||||
|
it('should send final results notification when stewarding closes', async () => {
|
||||||
|
// Given
|
||||||
|
const raceEvent = createTestRaceEvent({ status: 'awaiting_stewarding' });
|
||||||
|
await raceEventRepository.create(raceEvent);
|
||||||
|
|
||||||
|
const stewardingClosedEvent = new RaceEventStewardingClosedEvent({
|
||||||
|
raceEventId: raceEvent.id,
|
||||||
|
leagueId: raceEvent.leagueId,
|
||||||
|
seasonId: raceEvent.seasonId,
|
||||||
|
closedAt: new Date(),
|
||||||
|
driverIds: ['driver-1', 'driver-2'],
|
||||||
|
hadPenaltiesApplied: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// When
|
||||||
|
await sendFinalResultsUseCase.execute(stewardingClosedEvent);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(mockNotificationService.sendNotification).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
recipientId: 'driver-1',
|
||||||
|
type: 'race_final_results',
|
||||||
|
urgency: 'modal',
|
||||||
|
title: expect.stringContaining('Final Results'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Stewarding Window Management', () => {
|
||||||
|
it('should close expired stewarding windows', async () => {
|
||||||
|
// Given
|
||||||
|
const pastDate = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
|
||||||
|
const raceEvent = createTestRaceEvent({
|
||||||
|
status: 'awaiting_stewarding',
|
||||||
|
stewardingClosesAt: pastDate,
|
||||||
|
});
|
||||||
|
await raceEventRepository.create(raceEvent);
|
||||||
|
|
||||||
|
// When
|
||||||
|
await closeStewardingUseCase.execute({});
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const updatedEvent = await raceEventRepository.findById(raceEvent.id);
|
||||||
|
expect(updatedEvent?.status).toBe('closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not close unexpired stewarding windows', async () => {
|
||||||
|
// Given
|
||||||
|
const futureDate = new Date(Date.now() + 25 * 60 * 60 * 1000); // 25 hours from now
|
||||||
|
const raceEvent = createTestRaceEvent({
|
||||||
|
status: 'awaiting_stewarding',
|
||||||
|
stewardingClosesAt: futureDate,
|
||||||
|
});
|
||||||
|
await raceEventRepository.create(raceEvent);
|
||||||
|
|
||||||
|
// When
|
||||||
|
await closeStewardingUseCase.execute({});
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const updatedEvent = await raceEventRepository.findById(raceEvent.id);
|
||||||
|
expect(updatedEvent?.status).toBe('awaiting_stewarding');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Race Event Lifecycle', () => {
|
||||||
|
it('should transition from scheduled to in_progress when sessions start', () => {
|
||||||
|
// Given
|
||||||
|
const raceEvent = createTestRaceEvent({ status: 'scheduled' });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const startedEvent = raceEvent.start();
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(startedEvent.status).toBe('in_progress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transition to awaiting_stewarding when main race completes', () => {
|
||||||
|
// Given
|
||||||
|
const raceEvent = createTestRaceEvent({ status: 'in_progress' });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const completedEvent = raceEvent.completeMainRace();
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(completedEvent.status).toBe('awaiting_stewarding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transition to closed when stewarding closes', () => {
|
||||||
|
// Given
|
||||||
|
const raceEvent = createTestRaceEvent({ status: 'awaiting_stewarding' });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const closedEvent = raceEvent.closeStewarding();
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(closedEvent.status).toBe('closed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Session Type Behavior', () => {
|
||||||
|
it('should identify main race sessions correctly', () => {
|
||||||
|
// Given
|
||||||
|
const mainSession = createTestSession({ sessionType: SessionType.main() });
|
||||||
|
const practiceSession = createTestSession({ sessionType: SessionType.practice() });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(mainSession.countsForPoints()).toBe(true);
|
||||||
|
expect(practiceSession.countsForPoints()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify qualifying sessions correctly', () => {
|
||||||
|
// Given
|
||||||
|
const qualiSession = createTestSession({ sessionType: SessionType.qualifying() });
|
||||||
|
const mainSession = createTestSession({ sessionType: SessionType.main() });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(qualiSession.determinesGrid()).toBe(true);
|
||||||
|
expect(mainSession.determinesGrid()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user