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

View File

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

View File

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

View File

@@ -21,9 +21,11 @@ import {
Building2,
LogOut,
LogIn,
TrendingUp,
Award,
} 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';
interface NotificationOption {
@@ -63,6 +65,20 @@ const notificationOptions: NotificationOption[] = [
icon: Vote,
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[] = [
@@ -81,7 +97,7 @@ const urgencyOptions: UrgencyOption[] = [
{
urgency: 'modal',
label: 'Modal',
description: 'Shows blocking modal (must respond)',
description: 'Shows blocking modal (may require response)',
icon: AlertCircle,
},
];
@@ -193,7 +209,7 @@ export default function DevToolbar() {
let title: 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;
switch (selectedType) {
@@ -224,14 +240,38 @@ export default function DevToolbar() {
actionUrl = leagueId ? `/leagues/${leagueId}/stewarding` : '/leagues';
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 =
selectedUrgency === 'modal'
? [
{ label: 'View Protest', type: 'primary' as const, href: actionUrl, actionId: 'view' },
{ label: 'Dismiss', type: 'secondary' as const, actionId: 'dismiss' },
]
? selectedType.startsWith('race_')
? [
{ 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({
@@ -241,13 +281,25 @@ export default function DevToolbar() {
body,
actionUrl,
urgency: selectedUrgency as NotificationUrgency,
requiresResponse: selectedUrgency === 'modal',
requiresResponse: selectedUrgency === 'modal' && !selectedType.startsWith('race_'),
actions,
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 ?? '',
leagueId: primaryLeague?.id ?? '',
...(notificationDeadline ? { deadline: notificationDeadline } : {}),
...(notificationDeadline && selectedType.startsWith('protest_') ? { deadline: notificationDeadline } : {}),
},
});
@@ -315,7 +367,7 @@ export default function DevToolbar() {
</span>
</div>
<div className="grid grid-cols-3 gap-1">
<div className="grid grid-cols-2 gap-1">
{notificationOptions.map((option) => {
const Icon = option.icon;
const isSelected = selectedType === option.type;
@@ -436,7 +488,7 @@ export default function DevToolbar() {
<p className="text-[10px] text-gray-500">
<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">Modal:</strong> Blocking popup (requires action)
<strong className="text-gray-400">Modal:</strong> Blocking popup (may require action)
</p>
</div>

View 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>
);
}

View 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>
);
}

View File

@@ -13,12 +13,20 @@ import {
Flag,
AlertCircle,
Clock,
TrendingUp,
Award,
Star,
Medal,
Target,
Zap,
X,
} from 'lucide-react';
import Button from '@/components/ui/Button';
interface ModalNotificationProps {
notification: Notification;
onAction: (notification: Notification, actionId?: string) => void;
onDismiss?: (notification: Notification) => void;
}
const notificationIcons: Record<string, typeof Bell> = {
@@ -27,6 +35,8 @@ const notificationIcons: Record<string, typeof Bell> = {
protest_vote_required: Vote,
penalty_issued: AlertTriangle,
race_results_posted: Trophy,
race_performance_summary: Medal,
race_final_results: Star,
league_invite: Users,
race_reminder: Flag,
};
@@ -50,17 +60,30 @@ const notificationColors: Record<string, { bg: string; border: string; text: str
text: 'text-primary-blue',
glow: 'shadow-[0_0_60px_rgba(25,140,255,0.3)]',
},
penalty_issued: {
bg: 'bg-red-500/10',
border: 'border-red-500/50',
penalty_issued: {
bg: 'bg-red-500/10',
border: 'border-red-500/50',
text: 'text-red-400',
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({
notification,
onAction,
onDismiss,
}: ModalNotificationProps) {
const [isVisible, setIsVisible] = useState(false);
const router = useRouter();
@@ -71,6 +94,18 @@ export default function ModalNotification({
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) => {
onAction(notification, action.actionId);
if (action.href) {
@@ -97,18 +132,25 @@ export default function ModalNotification({
const deadline = notification.data?.deadline;
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 (
<div
className={`
fixed inset-0 z-[100] flex items-center justify-center p-4
transition-all duration-300
${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
className={`
w-full max-w-lg transform transition-all duration-300
${isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}
${isRaceNotification ? '' : ''}
`}
>
<div
@@ -116,38 +158,71 @@ export default function ModalNotification({
rounded-2xl border-2 ${colors.border} ${colors.bg}
backdrop-blur-md ${colors.glow}
overflow-hidden
${isRaceNotification ? 'relative' : ''}
`}
>
{/* Header with pulse animation */}
<div className={`relative px-6 py-4 ${colors.bg} border-b ${colors.border}`}>
{/* Animated pulse ring */}
<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' : ''}`}>
{/* Subtle pulse ring */}
<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 className="flex items-center gap-4">
<div className={`relative p-3 rounded-xl ${colors.bg} border ${colors.border}`}>
<Icon className={`w-6 h-6 ${colors.text}`} />
</div>
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
Action Required
</p>
<h2 className="text-xl font-bold text-white">
{notification.title}
</h2>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`relative p-3 rounded-xl ${colors.bg} border ${colors.border} ${isRaceNotification ? 'shadow-lg' : ''}`}>
<Icon className={`w-6 h-6 ${colors.text}`} />
</div>
<div>
<p className={`text-xs font-semibold uppercase tracking-wide ${isRaceNotification ? 'text-yellow-400' : 'text-gray-400'}`}>
{isRaceNotification ? (isPerformanceSummary ? '🏁 Race Complete!' : '🏆 Championship Update') : 'Action Required'}
</p>
<h2 className={`text-xl font-bold ${isRaceNotification ? 'text-white' : 'text-white'}`}>
{notification.title}
</h2>
</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>
{/* Body */}
<div className="px-6 py-5">
<p className="text-gray-300 leading-relaxed">
<div className={`px-6 py-5 ${isRaceNotification ? 'bg-gradient-to-b from-transparent to-yellow-500/5' : ''}`}>
<p className={`leading-relaxed ${isRaceNotification ? 'text-white text-lg font-medium' : 'text-gray-300'}`}>
{notification.body}
</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 */}
{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">
<Clock className="w-5 h-5 text-warning-amber" />
<div>
@@ -168,10 +243,11 @@ export default function ModalNotification({
</p>
</div>
)}
</div>
{/* 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 ? (
<div className="flex flex-wrap gap-3 justify-end">
{notification.actions.map((action, index) => (
@@ -179,23 +255,48 @@ export default function ModalNotification({
key={index}
variant={action.type === 'primary' ? 'primary' : 'secondary'}
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}
</Button>
))}
</div>
) : (
<div className="flex justify-end">
<Button variant="primary" onClick={handlePrimaryAction}>
{notification.actionUrl ? 'View Details' : 'Acknowledge'}
</Button>
<div className="flex flex-wrap gap-3 justify-end">
{isRaceNotification ? (
<>
<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>
{/* Cannot dismiss warning */}
{notification.requiresResponse && (
{notification.requiresResponse && !isRaceNotification && (
<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">
This notification requires your action and cannot be dismissed

View File

@@ -18,6 +18,7 @@ interface NotificationContextValue {
markAsRead: (notification: Notification) => Promise<void>;
dismissToast: (notification: Notification) => void;
respondToModal: (notification: Notification, actionId?: string) => Promise<void>;
dismissModal: (notification: Notification) => Promise<void>;
}
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 value: NotificationContextValue = {
@@ -142,6 +162,7 @@ export default function NotificationProvider({ children }: NotificationProviderP
markAsRead,
dismissToast,
respondToModal,
dismissModal,
};
return (
@@ -165,6 +186,7 @@ export default function NotificationProvider({ children }: NotificationProviderP
<ModalNotification
notification={modalNotification}
onAction={respondToModal}
onDismiss={dismissModal}
/>
)}
</NotificationContext.Provider>

View File

@@ -11,7 +11,7 @@ type ButtonAsLink = AnchorHTMLAttributes<HTMLAnchorElement> & {
};
type ButtonProps = (ButtonAsButton | ButtonAsLink) & {
variant?: 'primary' | 'secondary' | 'danger';
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
children: ReactNode;
};
@@ -27,7 +27,9 @@ export default function Button({
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',
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;
const classes = `${baseStyles} ${variantStyles[variant]} ${className}`;

View File

@@ -138,6 +138,7 @@ import { GetDriversLeaderboardUseCase } from '@gridpilot/racing/application/use-
import { GetTeamsLeaderboardUseCase } from '@gridpilot/racing/application/use-cases/GetTeamsLeaderboardUseCase';
import { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/use-cases/TransferLeagueOwnershipUseCase';
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 { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter';
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>(
DI_TOKENS.RaceRegistrationRepository,
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
const hasPrimaryOwnerMembership = seededMemberships.some(
(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
const seededJoinRequests: JoinRequest[] = [];
const demoLeagues = seedData.leagues.slice(0, 6);
@@ -857,6 +910,17 @@ export function configureDIContainer(): void {
new CancelRaceUseCase(raceRepository)
);
container.registerInstance(
DI_TOKENS.CompleteRaceUseCase,
new CompleteRaceUseCase(
raceRepository,
raceRegistrationRepository,
resultRepository,
standingRepository,
driverRatingProvider
)
);
container.registerInstance(
DI_TOKENS.CreateLeagueWithSeasonAndScoringUseCase,
new CreateLeagueWithSeasonAndScoringUseCase(

View File

@@ -345,6 +345,11 @@ class DIContainer {
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 {
this.ensureInitialized();
return getDIContainer().resolve<ImportRaceResultsUseCase>(DI_TOKENS.ImportRaceResultsUseCase);
@@ -702,6 +707,10 @@ export function getCancelRaceUseCase(): CancelRaceUseCase {
return DIContainer.getInstance().cancelRaceUseCase;
}
export function getCompleteRaceUseCase(): import('@gridpilot/racing/application/use-cases/CompleteRaceUseCase').CompleteRaceUseCase {
return DIContainer.getInstance().completeRaceUseCase;
}
export function getImportRaceResultsUseCase(): ImportRaceResultsUseCase {
return DIContainer.getInstance().importRaceResultsUseCase;
}

View File

@@ -44,6 +44,7 @@ export const DI_TOKENS = {
CreateLeagueWithSeasonAndScoringUseCase: Symbol.for('CreateLeagueWithSeasonAndScoringUseCase'),
TransferLeagueOwnershipUseCase: Symbol.for('TransferLeagueOwnershipUseCase'),
CancelRaceUseCase: Symbol.for('CancelRaceUseCase'),
CompleteRaceUseCase: Symbol.for('CompleteRaceUseCase'),
ImportRaceResultsUseCase: Symbol.for('ImportRaceResultsUseCase'),
// Queries - Dashboard

View 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);
}
}

View File

@@ -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>;
}

View File

@@ -64,6 +64,8 @@ export type NotificationType =
| 'race_registration_open' // Race registration is now open
| 'race_reminder' // Race starting soon reminder
| '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_invite' // You were invited to a 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_reminder: 'Race Reminder',
race_results_posted: 'Results Posted',
race_performance_summary: 'Performance Summary',
race_final_results: 'Final Results',
league_invite: 'League Invitation',
league_join_request: 'Join Request',
league_join_approved: 'Request Approved',
@@ -139,6 +143,8 @@ export function getNotificationTypePriority(type: NotificationType): number {
race_registration_open: 5,
race_reminder: 8,
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_join_request: 5,
league_join_approved: 7,

View File

@@ -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
}
}

View 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);
}
}
}

View File

@@ -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);
}
}

View 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}`);
}
}
}

View 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;
}
}

View File

@@ -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;
}
}

View 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
}
}

View File

@@ -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();
}
}

View File

@@ -7,8 +7,7 @@
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export type SessionType = 'practice' | 'qualifying' | 'race';
import type { SessionType } from '../value-objects/SessionType';
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
export class Race implements IEntity<string> {
@@ -80,7 +79,7 @@ export class Race implements IEntity<string> {
...(props.trackId !== undefined ? { trackId: props.trackId } : {}),
car: props.car,
...(props.carId !== undefined ? { carId: props.carId } : {}),
sessionType: props.sessionType ?? 'race',
sessionType: props.sessionType ?? SessionType.main(),
status: props.status ?? 'scheduled',
...(props.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}),
...(props.registeredCount !== undefined ? { registeredCount: props.registeredCount } : {}),

View 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';
}
}

View 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,
};
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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>;
}

View 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>;
}

View 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];
}
}

View 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');
}
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View 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>;
}

View File

@@ -96,7 +96,8 @@ export function createLeagues(ownerIds: string[]): League[] {
for (let i = 0; i < leagueCount; i++) {
const id = `league-${i + 1}`;
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];
let settings = {
@@ -209,6 +210,7 @@ export function createMemberships(
teamsByLeague.set(team.primaryLeagueId, list);
});
drivers.forEach((driver) => {
// Each driver participates in 13 leagues
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++) {
const id = `race-${i + 1}`;
const league = pickOne(leagues);
let league = pickOne(leagues);
const offsetDays = faker.number.int({ min: -30, max: 45 });
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(
Race.create({
@@ -278,6 +294,8 @@ export function createRaces(leagues: League[]): Race[] {
car: faker.helpers.arrayElement(cars),
sessionType: 'race',
status,
...(strengthOfField !== undefined ? { strengthOfField } : {}),
...(status === 'running' ? { registeredCount: faker.number.int({ min: 12, max: 20 }) } : {}),
}),
);
}

View 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

View 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);
});
});
});