This commit is contained in:
2025-12-05 12:24:38 +01:00
parent fb509607c1
commit 5a9cd28d5b
47 changed files with 5456 additions and 228 deletions

View File

@@ -0,0 +1,94 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import {
type LeagueScheduleDTO,
type LeagueSchedulePreviewDTO,
} from '@gridpilot/racing/application';
import { getPreviewLeagueScheduleQuery } from '@/lib/di-container';
interface RequestBody {
seasonStartDate?: string;
raceStartTime?: string;
timezoneId?: string;
recurrenceStrategy?: LeagueScheduleDTO['recurrenceStrategy'];
intervalWeeks?: number;
weekdays?: LeagueScheduleDTO['weekdays'];
monthlyOrdinal?: LeagueScheduleDTO['monthlyOrdinal'];
monthlyWeekday?: LeagueScheduleDTO['monthlyWeekday'];
plannedRounds?: number;
}
function toLeagueScheduleDTO(body: RequestBody): LeagueScheduleDTO {
const {
seasonStartDate,
raceStartTime,
timezoneId,
recurrenceStrategy,
intervalWeeks,
weekdays,
monthlyOrdinal,
monthlyWeekday,
plannedRounds,
} = body;
if (
!seasonStartDate ||
!raceStartTime ||
!timezoneId ||
!recurrenceStrategy ||
plannedRounds == null
) {
throw new Error(
'seasonStartDate, raceStartTime, timezoneId, recurrenceStrategy, and plannedRounds are required',
);
}
const dto: LeagueScheduleDTO = {
seasonStartDate,
raceStartTime,
timezoneId,
recurrenceStrategy,
plannedRounds,
};
if (intervalWeeks != null) {
dto.intervalWeeks = intervalWeeks;
}
if (weekdays && weekdays.length > 0) {
dto.weekdays = weekdays;
}
if (monthlyOrdinal != null) {
dto.monthlyOrdinal = monthlyOrdinal;
}
if (monthlyWeekday != null) {
dto.monthlyWeekday = monthlyWeekday;
}
return dto;
}
export async function POST(request: NextRequest) {
try {
const json = (await request.json()) as RequestBody;
const schedule = toLeagueScheduleDTO(json);
const query = getPreviewLeagueScheduleQuery();
const preview: LeagueSchedulePreviewDTO = await query.execute({
schedule,
maxRounds: 10,
});
return NextResponse.json(preview, { status: 200 });
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to preview schedule';
return NextResponse.json(
{
error: message,
},
{ status: 400 },
);
}
}

View File

@@ -9,18 +9,21 @@ import LeagueMembers from '@/components/leagues/LeagueMembers';
import LeagueSchedule from '@/components/leagues/LeagueSchedule'; import LeagueSchedule from '@/components/leagues/LeagueSchedule';
import LeagueAdmin from '@/components/leagues/LeagueAdmin'; import LeagueAdmin from '@/components/leagues/LeagueAdmin';
import StandingsTable from '@/components/leagues/StandingsTable'; import StandingsTable from '@/components/leagues/StandingsTable';
import LeagueScoringTab from '@/components/leagues/LeagueScoringTab';
import DriverIdentity from '@/components/drivers/DriverIdentity'; import DriverIdentity from '@/components/drivers/DriverIdentity';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import { League } from '@gridpilot/racing/domain/entities/League'; import { League } from '@gridpilot/racing/domain/entities/League';
import { Driver } from '@gridpilot/racing/domain/entities/Driver'; import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO'; import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO';
import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import { import {
getLeagueRepository, getLeagueRepository,
getRaceRepository, getRaceRepository,
getDriverRepository, getDriverRepository,
getGetLeagueDriverSeasonStatsQuery, getGetLeagueDriverSeasonStatsQuery,
getGetLeagueScoringConfigQuery,
getDriverStats, getDriverStats,
getAllDriverRankings, getAllDriverRankings,
} from '@/lib/di-container'; } from '@/lib/di-container';
@@ -36,9 +39,12 @@ export default function LeagueDetailPage() {
const [owner, setOwner] = useState<Driver | null>(null); const [owner, setOwner] = useState<Driver | null>(null);
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]); const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
const [drivers, setDrivers] = useState<DriverDTO[]>([]); const [drivers, setDrivers] = useState<DriverDTO[]>([]);
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'overview' | 'schedule' | 'standings' | 'admin'>('overview'); const [activeTab, setActiveTab] = useState<
'overview' | 'schedule' | 'standings' | 'scoring' | 'admin'
>('overview');
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
@@ -71,6 +77,11 @@ export default function LeagueDetailPage() {
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId }); const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
setStandings(leagueStandings); setStandings(leagueStandings);
// Load scoring configuration for the active season
const getLeagueScoringConfigQuery = getGetLeagueScoringConfigQuery();
const scoring = await getLeagueScoringConfigQuery.execute({ leagueId });
setScoringConfig(scoring);
// Load all drivers for standings and map to DTOs for UI components // Load all drivers for standings and map to DTOs for UI components
const allDrivers = await driverRepo.findAll(); const allDrivers = await driverRepo.findAll();
const driverDtos: DriverDTO[] = allDrivers const driverDtos: DriverDTO[] = allDrivers
@@ -100,9 +111,10 @@ export default function LeagueDetailPage() {
initialTab === 'overview' || initialTab === 'overview' ||
initialTab === 'schedule' || initialTab === 'schedule' ||
initialTab === 'standings' || initialTab === 'standings' ||
initialTab === 'scoring' ||
initialTab === 'admin' initialTab === 'admin'
) { ) {
setActiveTab(initialTab); setActiveTab(initialTab as typeof activeTab);
} }
}, [searchParams]); }, [searchParams]);
@@ -231,6 +243,16 @@ export default function LeagueDetailPage() {
> >
Standings Standings
</button> </button>
<button
onClick={() => setActiveTab('scoring')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'scoring'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Scoring
</button>
{isAdmin && ( {isAdmin && (
<button <button
onClick={() => setActiveTab('admin')} onClick={() => setActiveTab('admin')}
@@ -266,22 +288,36 @@ export default function LeagueDetailPage() {
</div> </div>
<div className="pt-4 border-t border-charcoal-outline"> <div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">League Settings</h3> <h3 className="text-white font-medium mb-3">At a glance</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div> <div>
<label className="text-sm text-gray-500">Points System</label> <h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p> Structure
</h4>
<p className="text-gray-200">
Solo {league.settings.maxDrivers ?? 32} drivers
</p>
</div> </div>
<div> <div>
<label className="text-sm text-gray-500">Session Duration</label> <h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
<p className="text-white">{league.settings.sessionDuration} minutes</p> Schedule
</h4>
<p className="text-gray-200">
{`? rounds • 30 min Qualifying • ${
typeof league.settings.sessionDuration === 'number'
? league.settings.sessionDuration
: 40
} min Races`}
</p>
</div> </div>
<div> <div>
<label className="text-sm text-gray-500">Qualifying Format</label> <h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p> Scoring & drops
</h4>
<p className="text-gray-200">
{league.settings.pointsSystem.toUpperCase()}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -439,6 +475,23 @@ export default function LeagueDetailPage() {
</Card> </Card>
)} )}
{activeTab === 'scoring' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Scoring</h2>
<LeagueScoringTab
scoringConfig={scoringConfig}
practiceMinutes={20}
qualifyingMinutes={30}
sprintRaceMinutes={20}
mainRaceMinutes={
typeof league.settings.sessionDuration === 'number'
? league.settings.sessionDuration
: 40
}
/>
</Card>
)}
{activeTab === 'admin' && isAdmin && ( {activeTab === 'admin' && isAdmin && (
<LeagueAdmin <LeagueAdmin
league={league} league={league}

View File

@@ -0,0 +1,15 @@
'use client';
import CreateLeagueWizard from '@/components/leagues/CreateLeagueWizard';
import Section from '@/components/ui/Section';
import Container from '@/components/ui/Container';
export default function CreateLeaguePage() {
return (
<Section>
<Container size="md">
<CreateLeagueWizard />
</Container>
</Section>
);
}

View File

@@ -3,18 +3,16 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import LeagueCard from '@/components/leagues/LeagueCard'; import LeagueCard from '@/components/leagues/LeagueCard';
import CreateLeagueForm from '@/components/leagues/CreateLeagueForm';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO'; import type { LeagueSummaryDTO } from '@gridpilot/racing/application/dto/LeagueSummaryDTO';
import { getGetAllLeaguesWithCapacityQuery } from '@/lib/di-container'; import { getGetAllLeaguesWithCapacityAndScoringQuery } from '@/lib/di-container';
export default function LeaguesPage() { export default function LeaguesPage() {
const router = useRouter(); const router = useRouter();
const [leagues, setLeagues] = useState<LeagueDTO[]>([]); const [leagues, setLeagues] = useState<LeagueSummaryDTO[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name'); const [sortBy, setSortBy] = useState('name');
@@ -24,7 +22,7 @@ export default function LeaguesPage() {
const loadLeagues = async () => { const loadLeagues = async () => {
try { try {
const query = getGetAllLeaguesWithCapacityQuery(); const query = getGetAllLeaguesWithCapacityAndScoringQuery();
const allLeagues = await query.execute(); const allLeagues = await query.execute();
setLeagues(allLeagues); setLeagues(allLeagues);
} catch (error) { } catch (error) {
@@ -78,24 +76,12 @@ export default function LeaguesPage() {
<Button <Button
variant="primary" variant="primary"
onClick={() => setShowCreateForm(!showCreateForm)} onClick={() => router.push('/leagues/create')}
> >
{showCreateForm ? 'Cancel' : 'Create League'} Create League
</Button> </Button>
</div> </div>
{showCreateForm && (
<Card className="mb-8 max-w-2xl mx-auto">
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">Create New League</h2>
<p className="text-gray-400 text-sm">
Experiment with different point systems
</p>
</div>
<CreateLeagueForm />
</Card>
)}
{leagues.length > 0 && ( {leagues.length > 0 && (
<Card className="mb-8"> <Card className="mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -0,0 +1,587 @@
'use client';
import { useEffect, useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import LeagueReviewSummary from './LeagueReviewSummary';
import {
getDriverRepository,
getListLeagueScoringPresetsQuery,
getCreateLeagueWithSeasonAndScoringUseCase,
} from '@/lib/di-container';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import { LeagueBasicsSection } from './LeagueBasicsSection';
import { LeagueStructureSection } from './LeagueStructureSection';
import {
LeagueScoringSection,
ScoringPatternSection,
ChampionshipsSection,
} from './LeagueScoringSection';
import { LeagueDropSection } from './LeagueDropSection';
import { LeagueTimingsSection } from './LeagueTimingsSection';
type Step = 1 | 2 | 3 | 4 | 5 | 6;
interface WizardErrors {
basics?: {
name?: string;
visibility?: string;
};
structure?: {
maxDrivers?: string;
maxTeams?: string;
driversPerTeam?: string;
};
timings?: {
qualifyingMinutes?: string;
mainRaceMinutes?: string;
roundsPlanned?: string;
};
scoring?: {
patternId?: string;
};
submit?: string;
}
function createDefaultForm(): LeagueConfigFormModel {
const defaultPatternId = 'sprint-main-driver';
return {
basics: {
name: '',
description: '',
visibility: 'public',
gameId: 'iracing',
},
structure: {
mode: 'solo',
maxDrivers: 24,
maxTeams: undefined,
driversPerTeam: undefined,
multiClassEnabled: false,
},
championships: {
enableDriverChampionship: true,
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
},
scoring: {
patternId: defaultPatternId,
customScoringEnabled: false,
},
dropPolicy: {
strategy: 'bestNResults',
n: 6,
},
timings: {
practiceMinutes: 20,
qualifyingMinutes: 30,
sprintRaceMinutes: defaultPatternId === 'sprint-main-driver' ? 20 : undefined,
mainRaceMinutes: 40,
sessionCount: 2,
roundsPlanned: 8,
},
};
}
export default function CreateLeagueWizard() {
const router = useRouter();
const [step, setStep] = useState<Step>(1);
const [loading, setLoading] = useState(false);
const [presetsLoading, setPresetsLoading] = useState(true);
const [presets, setPresets] = useState<LeagueScoringPresetDTO[]>([]);
const [errors, setErrors] = useState<WizardErrors>({});
const [form, setForm] = useState<LeagueConfigFormModel>(() =>
createDefaultForm(),
);
/**
* Local-only weekend template selection for Step 3.
* This does not touch domain models; it only seeds timing defaults.
*/
const [weekendTemplate, setWeekendTemplate] = useState<string>('');
useEffect(() => {
async function loadPresets() {
try {
const query = getListLeagueScoringPresetsQuery();
const result = await query.execute();
setPresets(result);
if (result.length > 0) {
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
patternId: prev.scoring.patternId || result[0].id,
customScoringEnabled: prev.scoring.customScoringEnabled ?? false,
},
}));
}
} catch (err) {
setErrors((prev) => ({
...prev,
submit:
err instanceof Error
? err.message
: 'Failed to load scoring presets',
}));
} finally {
setPresetsLoading(false);
}
}
loadPresets();
}, []);
const validateStep = (currentStep: Step): boolean => {
const nextErrors: WizardErrors = {};
if (currentStep === 1) {
const basicsErrors: WizardErrors['basics'] = {};
if (!form.basics.name.trim()) {
basicsErrors.name = 'Name is required';
}
if (!form.basics.visibility) {
basicsErrors.visibility = 'Visibility is required';
}
if (Object.keys(basicsErrors).length > 0) {
nextErrors.basics = basicsErrors;
}
}
if (currentStep === 2) {
const structureErrors: WizardErrors['structure'] = {};
if (form.structure.mode === 'solo') {
if (!form.structure.maxDrivers || form.structure.maxDrivers <= 0) {
structureErrors.maxDrivers =
'Max drivers must be greater than 0 for solo leagues';
}
} else if (form.structure.mode === 'fixedTeams') {
if (
!form.structure.maxTeams ||
form.structure.maxTeams <= 0
) {
structureErrors.maxTeams =
'Max teams must be greater than 0 for team leagues';
}
if (
!form.structure.driversPerTeam ||
form.structure.driversPerTeam <= 0
) {
structureErrors.driversPerTeam =
'Drivers per team must be greater than 0';
}
}
if (Object.keys(structureErrors).length > 0) {
nextErrors.structure = structureErrors;
}
}
if (currentStep === 3) {
const timingsErrors: WizardErrors['timings'] = {};
if (!form.timings.qualifyingMinutes || form.timings.qualifyingMinutes <= 0) {
timingsErrors.qualifyingMinutes =
'Qualifying duration must be greater than 0 minutes';
}
if (!form.timings.mainRaceMinutes || form.timings.mainRaceMinutes <= 0) {
timingsErrors.mainRaceMinutes =
'Main race duration must be greater than 0 minutes';
}
if (Object.keys(timingsErrors).length > 0) {
nextErrors.timings = timingsErrors;
}
}
if (currentStep === 4) {
const scoringErrors: WizardErrors['scoring'] = {};
if (!form.scoring.patternId && !form.scoring.customScoringEnabled) {
scoringErrors.patternId =
'Select a scoring preset or enable custom scoring';
}
if (Object.keys(scoringErrors).length > 0) {
nextErrors.scoring = scoringErrors;
}
}
setErrors((prev) => ({
...prev,
...nextErrors,
}));
return Object.keys(nextErrors).length === 0;
};
const goToNextStep = () => {
if (!validateStep(step)) {
return;
}
setStep((prev) => (prev < 6 ? ((prev + 1) as Step) : prev));
};
const goToPreviousStep = () => {
setStep((prev) => (prev > 1 ? ((prev - 1) as Step) : prev));
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (loading) return;
if (
!validateStep(1) ||
!validateStep(2) ||
!validateStep(3) ||
!validateStep(4) ||
!validateStep(5)
) {
setStep(1);
return;
}
setLoading(true);
setErrors((prev) => ({ ...prev, submit: undefined }));
try {
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const currentDriver = drivers[0];
if (!currentDriver) {
setErrors((prev) => ({
...prev,
submit:
'No driver profile found. Please create a driver profile first.',
}));
setLoading(false);
return;
}
const createUseCase = getCreateLeagueWithSeasonAndScoringUseCase();
const structure = form.structure;
let maxDrivers: number | undefined;
let maxTeams: number | undefined;
if (structure.mode === 'solo') {
maxDrivers =
typeof structure.maxDrivers === 'number'
? structure.maxDrivers
: undefined;
maxTeams = undefined;
} else {
const teams =
typeof structure.maxTeams === 'number' ? structure.maxTeams : 0;
const perTeam =
typeof structure.driversPerTeam === 'number'
? structure.driversPerTeam
: 0;
maxTeams = teams > 0 ? teams : undefined;
maxDrivers =
teams > 0 && perTeam > 0 ? teams * perTeam : undefined;
}
const command = {
name: form.basics.name.trim(),
description: form.basics.description?.trim() || undefined,
visibility: form.basics.visibility,
ownerId: currentDriver.id,
gameId: form.basics.gameId,
maxDrivers,
maxTeams,
enableDriverChampionship: form.championships.enableDriverChampionship,
enableTeamChampionship: form.championships.enableTeamChampionship,
enableNationsChampionship:
form.championships.enableNationsChampionship,
enableTrophyChampionship:
form.championships.enableTrophyChampionship,
scoringPresetId: form.scoring.patternId || undefined,
} as const;
const result = await createUseCase.execute(command);
router.push(`/leagues/${result.leagueId}`);
} catch (err) {
setErrors((prev) => ({
...prev,
submit:
err instanceof Error ? err.message : 'Failed to create league',
}));
setLoading(false);
}
};
const currentPreset =
presets.find((p) => p.id === form.scoring.patternId) ?? null;
const handleWeekendTemplateChange = (template: string) => {
setWeekendTemplate(template);
setForm((prev) => {
const timings = prev.timings ?? {};
if (template === 'feature') {
return {
...prev,
timings: {
...timings,
practiceMinutes: 20,
qualifyingMinutes: 30,
sprintRaceMinutes: undefined,
mainRaceMinutes: 40,
sessionCount: 1,
},
};
}
if (template === 'sprintFeature') {
return {
...prev,
timings: {
...timings,
practiceMinutes: 15,
qualifyingMinutes: 20,
sprintRaceMinutes: 20,
mainRaceMinutes: 35,
sessionCount: 2,
},
};
}
if (template === 'endurance') {
return {
...prev,
timings: {
...timings,
practiceMinutes: 30,
qualifyingMinutes: 30,
sprintRaceMinutes: undefined,
mainRaceMinutes: 90,
sessionCount: 1,
},
};
}
return prev;
});
};
const steps = [
{ id: 1 as Step, label: 'Basics' },
{ id: 2 as Step, label: 'Structure' },
{ id: 3 as Step, label: 'Schedule & timings' },
{ id: 4 as Step, label: 'Scoring pattern' },
{ id: 5 as Step, label: 'Championships & drops' },
{ id: 6 as Step, label: 'Review & confirm' },
];
const getStepTitle = (currentStep: Step): string => {
switch (currentStep) {
case 1:
return 'Step 1 — Basics';
case 2:
return 'Step 2 — Structure';
case 3:
return 'Step 3 — Schedule & timings';
case 4:
return 'Step 4 — Scoring pattern';
case 5:
return 'Step 5 — Championships & drops';
case 6:
return 'Step 6 — Review & confirm';
default:
return '';
}
};
const getStepSubtitle = (currentStep: Step): string => {
switch (currentStep) {
case 1:
return 'Give your league a clear name, description, and visibility.';
case 2:
return 'Choose whether this is a solo or team-based championship.';
case 3:
return 'Roughly outline how long your weekends and season should run.';
case 4:
return 'Pick a scoring pattern that matches your weekends.';
case 5:
return 'Decide which championships to track and how drops behave.';
case 6:
return 'Double-check the summary before creating your new league.';
default:
return '';
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<Heading level={1} className="mb-2">
Create a new league
</Heading>
<p className="text-sm text-gray-400 mb-4">
Configure basics, structure, schedule, scoring, and drop rules in a few
simple steps.
</p>
<div className="mb-4 flex flex-col gap-2">
<div className="flex flex-wrap gap-3">
{steps.map((wizardStep, index) => {
const isCompleted = wizardStep.id < step;
const isCurrent = wizardStep.id === step;
const baseCircleClasses =
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold';
const circleClasses = isCurrent
? 'bg-primary-blue text-white'
: isCompleted
? 'bg-primary-blue/20 border border-primary-blue text-primary-blue'
: 'bg-iron-gray border border-charcoal-outline text-gray-400';
return (
<div key={wizardStep.id} className="flex items-center gap-2">
<div className={baseCircleClasses + ' ' + circleClasses}>
{isCompleted ? '✓' : wizardStep.id}
</div>
<span
className={`text-xs ${
isCurrent
? 'text-white'
: isCompleted
? 'text-gray-300'
: 'text-gray-500'
}`}
>
{wizardStep.label}
</span>
{index < steps.length - 1 && (
<span className="mx-1 h-px w-6 bg-charcoal-outline/70" />
)}
</div>
);
})}
</div>
</div>
<Card>
<div>
<Heading level={2} className="text-2xl text-white">
{getStepTitle(step)}
</Heading>
<p className="mt-1 text-sm text-gray-400">
{getStepSubtitle(step)}
</p>
<hr className="my-4 border-charcoal-outline/40" />
</div>
{step === 1 && (
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics}
/>
)}
{step === 2 && (
<LeagueStructureSection
form={form}
onChange={setForm}
readOnly={false}
/>
)}
{step === 3 && (
<LeagueTimingsSection
form={form}
onChange={setForm}
errors={errors.timings}
weekendTemplate={weekendTemplate}
onWeekendTemplateChange={handleWeekendTemplateChange}
/>
)}
{step === 4 && (
<div className="space-y-4">
<ScoringPatternSection
scoring={form.scoring}
presets={presets}
readOnly={presetsLoading}
patternError={errors.scoring?.patternId}
onChangePatternId={(patternId) =>
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
patternId,
customScoringEnabled: false,
},
}))
}
onToggleCustomScoring={() =>
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
customScoringEnabled: !prev.scoring.customScoringEnabled,
},
}))
}
/>
</div>
)}
{step === 5 && (
<div className="space-y-6">
<div className="space-y-6">
<ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} />
</div>
<div className="space-y-3">
<LeagueDropSection form={form} onChange={setForm} readOnly={false} />
</div>
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-3 border border-warning-amber/20 text-xs text-warning-amber">
{errors.submit}
</div>
)}
</div>
)}
{step === 6 && (
<div className="space-y-6">
<LeagueReviewSummary form={form} presets={presets} />
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-3 border border-warning-amber/20 text-xs text-warning-amber">
{errors.submit}
</div>
)}
</div>
)}
</Card>
<div className="flex justify-between items-center">
<Button
type="button"
variant="secondary"
disabled={step === 1 || loading}
onClick={goToPreviousStep}
>
Back
</Button>
<div className="flex gap-2">
{step < 6 && (
<Button
type="button"
variant="primary"
disabled={loading}
onClick={goToNextStep}
>
Next
</Button>
)}
{step === 6 && (
<Button type="submit" variant="primary" disabled={loading}>
{loading ? 'Creating…' : 'Create league'}
</Button>
)}
</div>
</div>
</form>
);
}

View File

@@ -12,7 +12,14 @@ import {
getDriverStats, getDriverStats,
getAllDriverRankings, getAllDriverRankings,
getDriverRepository, getDriverRepository,
getGetLeagueFullConfigQuery,
} from '@/lib/di-container'; } from '@/lib/di-container';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import { LeagueBasicsSection } from './LeagueBasicsSection';
import { LeagueStructureSection } from './LeagueStructureSection';
import { LeagueScoringSection } from './LeagueScoringSection';
import { LeagueDropSection } from './LeagueDropSection';
import { LeagueTimingsSection } from './LeagueTimingsSection';
import { useEffectiveDriverId } from '@/lib/currentDriver'; import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { MembershipRole } from '@/lib/leagueMembership'; import type { MembershipRole } from '@/lib/leagueMembership';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
@@ -46,6 +53,8 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'disputes'>('members'); const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'disputes'>('members');
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
const [configForm, setConfigForm] = useState<LeagueConfigFormModel | null>(null);
const [configLoading, setConfigLoading] = useState(false);
const loadJoinRequests = useCallback(async () => { const loadJoinRequests = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -93,6 +102,23 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
loadOwner(); loadOwner();
}, [league.ownerId]); }, [league.ownerId]);
useEffect(() => {
async function loadConfig() {
setConfigLoading(true);
try {
const query = getGetLeagueFullConfigQuery();
const form = await query.execute({ leagueId: league.id });
setConfigForm(form);
} catch (err) {
console.error('Failed to load league config:', err);
} finally {
setConfigLoading(false);
}
}
loadConfig();
}, [league.id]);
const handleApproveRequest = async (requestId: string) => { const handleApproveRequest = async (requestId: string) => {
try { try {
const membershipRepo = getLeagueMembershipRepository(); const membershipRepo = getLeagueMembershipRepository();
@@ -464,113 +490,74 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
<Card> <Card>
<h2 className="text-xl font-semibold text-white mb-4">League Settings</h2> <h2 className="text-xl font-semibold text-white mb-4">League Settings</h2>
<div className="space-y-6"> {configLoading && !configForm ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="py-6 text-sm text-gray-400">Loading configuration</div>
<div className="lg:col-span-2 space-y-4"> ) : configForm ? (
<div> <div className="space-y-8">
<label className="block text-sm font-medium text-gray-300 mb-2"> <LeagueBasicsSection form={configForm} readOnly />
League Name <LeagueStructureSection form={configForm} readOnly />
</label> <LeagueTimingsSection form={configForm} readOnly />
<p className="text-white">{league.name}</p> <LeagueScoringSection form={configForm} presets={[]} readOnly />
</div> <LeagueDropSection form={configForm} readOnly />
<div> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<label className="block text-sm font-medium text-gray-300 mb-2"> <div className="lg:col-span-2 space-y-4">
Description
</label>
<p className="text-white">{league.description}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2 border-t border-charcoal-outline">
<div> <div>
<label className="text-sm text-gray-500">Season / Series</label> <label className="block text-sm font-medium text-gray-300 mb-2">
Season / Series
</label>
<p className="text-white">Alpha Demo Season</p> <p className="text-white">Alpha Demo Season</p>
</div> </div>
<div> <div className="rounded-lg border border-charcoal-outline bg-iron-gray/60 p-4 space-y-2">
<label className="text-sm text-gray-500">Points System</label> <h3 className="text-sm font-semibold text-gray-200 mb-1">
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p> At a glance
</div> </h3>
<div> <p className="text-xs text-gray-300">
<label className="text-sm text-gray-500">Qualifying Format</label> <span className="font-semibold text-gray-200">Structure:</span>{' '}
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p> {configForm.structure.mode === 'solo'
? `Solo • ${configForm.structure.maxDrivers} drivers`
: `Teams • ${configForm.structure.maxTeams ?? '—'} × ${
configForm.structure.driversPerTeam ?? '—'
} drivers (${configForm.structure.maxDrivers ?? '—'} total)`}
</p>
<p className="text-xs text-gray-300">
<span className="font-semibold text-gray-200">Schedule:</span>{' '}
{`${configForm.timings.roundsPlanned ?? '?'} rounds • ${
configForm.timings.qualifyingMinutes
} min Qualifying • ${configForm.timings.mainRaceMinutes} min Race`}
</p>
<p className="text-xs text-gray-300">
<span className="font-semibold text-gray-200">Scoring:</span>{' '}
{league.settings.pointsSystem.toUpperCase()}
</p>
</div> </div>
</div> </div>
{league.socialLinks && ( <div className="space-y-3">
<div className="pt-4 border-t border-charcoal-outline space-y-2"> <h3 className="text-sm font-medium text-gray-300">League Owner</h3>
<h3 className="text-sm font-medium text-gray-300">Social Links</h3> {ownerSummary ? (
<div className="space-y-1 text-sm"> <DriverSummaryPill
{league.socialLinks.discordUrl && ( driver={ownerSummary.driver}
<div className="flex items-center justify-between gap-3"> rating={ownerSummary.rating}
<span className="text-gray-400">Discord</span> rank={ownerSummary.rank}
<a />
href={league.socialLinks.discordUrl} ) : (
target="_blank" <p className="text-sm text-gray-500">Loading owner details...</p>
rel="noreferrer" )}
className="text-primary-blue hover:underline break-all" </div>
>
{league.socialLinks.discordUrl}
</a>
</div>
)}
{league.socialLinks.youtubeUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">YouTube</span>
<a
href={league.socialLinks.youtubeUrl}
target="_blank"
rel="noreferrer"
className="text-red-400 hover:underline break-all"
>
{league.socialLinks.youtubeUrl}
</a>
</div>
)}
{league.socialLinks.websiteUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">Website</span>
<a
href={league.socialLinks.websiteUrl}
target="_blank"
rel="noreferrer"
className="text-gray-100 hover:underline break-all"
>
{league.socialLinks.websiteUrl}
</a>
</div>
)}
{!league.socialLinks.discordUrl &&
!league.socialLinks.youtubeUrl &&
!league.socialLinks.websiteUrl && (
<p className="text-gray-500">
No social links configured for this league in the alpha demo.
</p>
)}
</div>
</div>
)}
</div> </div>
<div className="space-y-3"> <div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-sm font-medium text-gray-300">League Owner</h3> <p className="text-sm text-gray-400">
{ownerSummary ? ( League settings editing is alpha-only and changes are not persisted yet.
<DriverSummaryPill </p>
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
/>
) : (
<p className="text-sm text-gray-500">Loading owner details...</p>
)}
</div> </div>
</div> </div>
) : (
<div className="pt-4 border-t border-charcoal-outline"> <div className="py-6 text-sm text-gray-500">
<p className="text-sm text-gray-400"> Unable to load league configuration for this demo league.
League settings editing is alpha-only and changes are not persisted yet.
</p>
</div> </div>
</div> )}
</Card> </Card>
)} )}

View File

@@ -0,0 +1,130 @@
'use client';
import Input from '@/components/ui/Input';
import type {
LeagueConfigFormModel,
} from '@gridpilot/racing/application';
interface LeagueBasicsSectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
errors?: {
name?: string;
visibility?: string;
};
readOnly?: boolean;
}
export function LeagueBasicsSection({
form,
onChange,
errors,
readOnly,
}: LeagueBasicsSectionProps) {
const basics = form.basics;
const disabled = readOnly || !onChange;
const updateBasics = (patch: Partial<typeof basics>) => {
if (!onChange) return;
onChange({
...form,
basics: {
...form.basics,
...patch,
},
});
};
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-white">Step 1 Basics</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
League name *
</label>
<Input
value={basics.name}
onChange={(e) => updateBasics({ name: e.target.value })}
placeholder="GridPilot Sprint Series"
error={!!errors?.name}
errorMessage={errors?.name}
disabled={disabled}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<textarea
value={basics.description ?? ''}
onChange={(e) =>
updateBasics({
description: e.target.value,
})
}
rows={3}
disabled={disabled}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue text-sm disabled:opacity-60 disabled:cursor-not-allowed"
placeholder="Weekly league with structured championships and live standings."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<span className="block text-sm font-medium text-gray-300 mb-2">
Visibility *
</span>
<div className="flex gap-3">
<button
type="button"
disabled={disabled}
onClick={() =>
updateBasics({
visibility: 'public',
})
}
className={`flex-1 px-3 py-2 text-xs rounded-md border ${
basics.visibility === 'public'
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
: 'border-charcoal-outline bg-iron-gray text-gray-300'
} ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Public
</button>
<button
type="button"
disabled={disabled}
onClick={() =>
updateBasics({
visibility: 'private',
})
}
className={`flex-1 px-3 py-2 text-xs rounded-md border ${
basics.visibility === 'private'
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
: 'border-charcoal-outline bg-iron-gray text-gray-300'
} ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Private
</button>
</div>
{errors?.visibility && (
<p className="mt-1 text-xs text-warning-amber">
{errors.visibility}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Game
</label>
<Input value="iRacing" disabled />
</div>
</div>
</div>
</div>
);
}

View File

@@ -2,13 +2,13 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO'; import type { LeagueSummaryDTO } from '@gridpilot/racing/application/dto/LeagueSummaryDTO';
import Card from '../ui/Card'; import Card from '../ui/Card';
import { getLeagueCoverClasses } from '@/lib/leagueCovers'; import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import { getImageService } from '@/lib/di-container'; import { getImageService } from '@/lib/di-container';
interface LeagueCardProps { interface LeagueCardProps {
league: LeagueDTO; league: LeagueSummaryDTO;
onClick?: () => void; onClick?: () => void;
} }
@@ -57,6 +57,22 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
<p className="text-gray-400 text-sm line-clamp-2"> <p className="text-gray-400 text-sm line-clamp-2">
{league.description} {league.description}
</p> </p>
{league.structureSummary && (
<p className="text-xs text-gray-400">
{league.structureSummary}
</p>
)}
{league.scoringPatternSummary && (
<p className="text-xs text-gray-400">
{league.scoringPatternSummary}
</p>
)}
{league.timingSummary && (
<p className="text-xs text-gray-400">
{league.timingSummary}
</p>
)}
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline"> <div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<div className="flex flex-col text-xs text-gray-500"> <div className="flex flex-col text-xs text-gray-500">
@@ -70,19 +86,55 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
</Link> </Link>
</span> </span>
<span className="mt-1 text-gray-400"> <span className="mt-1 text-gray-400">
Slots:{' '} Drivers:{' '}
<span className="text-white font-medium"> <span className="text-white font-medium">
{typeof league.usedSlots === 'number' ? league.usedSlots : '—'} {typeof league.usedDriverSlots === 'number'
? league.usedDriverSlots
: '—'}
</span> </span>
{' / '} {' / '}
<span className="text-gray-300"> <span className="text-gray-300">
{league.settings.maxDrivers ?? '—'} {league.maxDrivers ?? '—'}
</span>{' '} </span>
used
</span> </span>
{typeof league.usedTeamSlots === 'number' ||
typeof league.maxTeams === 'number' ? (
<span className="mt-0.5 text-gray-400">
Teams:{' '}
<span className="text-white font-medium">
{typeof league.usedTeamSlots === 'number'
? league.usedTeamSlots
: '—'}
</span>
{' / '}
<span className="text-gray-300">
{league.maxTeams ?? '—'}
</span>
</span>
) : null}
</div> </div>
<div className="text-xs text-primary-blue font-medium"> <div className="flex flex-col items-end text-xs text-gray-400">
{league.settings.pointsSystem.toUpperCase()} {league.scoring ? (
<>
<span className="text-primary-blue font-semibold">
{league.scoring.gameName}
</span>
<span className="mt-0.5">
{league.scoring.primaryChampionshipType === 'driver'
? 'Driver championship'
: league.scoring.primaryChampionshipType === 'team'
? 'Team championship'
: league.scoring.primaryChampionshipType === 'nations'
? 'Nations championship'
: 'Trophy championship'}
</span>
<span className="mt-0.5">
{league.scoring.scoringPatternSummary}
</span>
</>
) : (
<span className="text-gray-500">Scoring: Not configured</span>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,137 @@
'use client';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import Input from '@/components/ui/Input';
import SegmentedControl from '@/components/ui/SegmentedControl';
interface LeagueDropSectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
}
export function LeagueDropSection({
form,
onChange,
readOnly,
}: LeagueDropSectionProps) {
const disabled = readOnly || !onChange;
const dropPolicy = form.dropPolicy;
const updateDropPolicy = (
patch: Partial<LeagueConfigFormModel['dropPolicy']>,
) => {
if (!onChange) return;
onChange({
...form,
dropPolicy: {
...dropPolicy,
...patch,
},
});
};
const handleStrategyChange = (
strategy: LeagueConfigFormModel['dropPolicy']['strategy'],
) => {
if (strategy === 'none') {
updateDropPolicy({ strategy: 'none', n: undefined });
} else if (strategy === 'bestNResults') {
const n = dropPolicy.n ?? 6;
updateDropPolicy({ strategy: 'bestNResults', n });
} else if (strategy === 'dropWorstN') {
const n = dropPolicy.n ?? 2;
updateDropPolicy({ strategy: 'dropWorstN', n });
}
};
const handleNChange = (value: string) => {
const parsed = parseInt(value, 10);
updateDropPolicy({
n: Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed,
});
};
const computeSummary = () => {
if (dropPolicy.strategy === 'none') {
return 'All results will count towards the championship.';
}
if (dropPolicy.strategy === 'bestNResults') {
const n = dropPolicy.n;
if (typeof n === 'number' && n > 0) {
return `Best ${n} results will count; others are ignored.`;
}
return 'Best N results will count; others are ignored.';
}
if (dropPolicy.strategy === 'dropWorstN') {
const n = dropPolicy.n;
if (typeof n === 'number' && n > 0) {
return `Worst ${n} results will be dropped from the standings.`;
}
return 'Worst N results will be dropped from the standings.';
}
return 'All results will count towards the championship.';
};
const currentStrategyValue =
dropPolicy.strategy === 'none'
? 'all'
: dropPolicy.strategy === 'bestNResults'
? 'bestN'
: 'dropWorstN';
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-white">Drop rule</h3>
<p className="text-xs text-gray-400">
Decide whether to count every round or ignore a few worst results.
</p>
<SegmentedControl
options={[
{ value: 'all', label: 'All count' },
{ value: 'bestN', label: 'Best N' },
{ value: 'dropWorstN', label: 'Drop worst N' },
]}
value={currentStrategyValue}
onChange={(value) => {
if (disabled) return;
if (value === 'all') {
handleStrategyChange('none');
} else if (value === 'bestN') {
handleStrategyChange('bestNResults');
} else if (value === 'dropWorstN') {
handleStrategyChange('dropWorstN');
}
}}
/>
{(dropPolicy.strategy === 'bestNResults' ||
dropPolicy.strategy === 'dropWorstN') && (
<div className="mt-2 max-w-[140px]">
<label className="mb-1 block text-xs font-medium text-gray-300">
N
</label>
<Input
type="number"
value={
typeof dropPolicy.n === 'number' && dropPolicy.n > 0
? String(dropPolicy.n)
: ''
}
onChange={(e) => handleNChange(e.target.value)}
disabled={disabled}
min={1}
/>
<p className="mt-1 text-[11px] text-gray-500">
{dropPolicy.strategy === 'bestNResults'
? 'For example, best 6 of 10 rounds count.'
: 'For example, drop the worst 2 results.'}
</p>
</div>
)}
<p className="mt-3 text-xs text-gray-400">{computeSummary()}</p>
</div>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import Card from '@/components/ui/Card';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
interface LeagueReviewSummaryProps {
form: LeagueConfigFormModel;
presets: LeagueScoringPresetDTO[];
}
export default function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) {
const { basics, structure, timings, scoring, championships, dropPolicy } = form;
const modeLabel =
structure.mode === 'solo'
? 'Drivers only (solo)'
: 'Teams with fixed drivers per team';
const capacitySentence = (() => {
if (structure.mode === 'solo') {
if (typeof structure.maxDrivers === 'number') {
return `Up to ${structure.maxDrivers} drivers`;
}
return 'Capacity not fully specified';
}
const parts: string[] = [];
if (typeof structure.maxTeams === 'number') {
parts.push(`Teams: ${structure.maxTeams}`);
}
if (typeof structure.driversPerTeam === 'number') {
parts.push(`Drivers per team: ${structure.driversPerTeam}`);
}
if (typeof structure.maxDrivers === 'number') {
parts.push(`Max grid: ${structure.maxDrivers}`);
}
if (parts.length === 0) {
return '—';
}
return parts.join(', ');
})();
const formatMinutes = (value: number | undefined) => {
if (typeof value !== 'number' || value <= 0) return '—';
return `${value} min`;
};
const dropRuleSentence = (() => {
if (dropPolicy.strategy === 'none') {
return 'All results will count towards the championship.';
}
if (dropPolicy.strategy === 'bestNResults') {
if (typeof dropPolicy.n === 'number' && dropPolicy.n > 0) {
return `Best ${dropPolicy.n} results will count; others are ignored.`;
}
return 'Best N results will count; others are ignored.';
}
if (dropPolicy.strategy === 'dropWorstN') {
if (typeof dropPolicy.n === 'number' && dropPolicy.n > 0) {
return `Worst ${dropPolicy.n} results will be dropped from the standings.`;
}
return 'Worst N results will be dropped from the standings.';
}
return 'All results will count towards the championship.';
})();
const preset =
presets.find((p) => p.id === scoring.patternId) ?? null;
const scoringPresetName = preset ? preset.name : scoring.patternId ? 'Preset not found' : '—';
const scoringPatternSummary = preset?.sessionSummary ?? '—';
const dropPolicySummary = dropRuleSentence;
const enabledChampionshipsLabels: string[] = [];
if (championships.enableDriverChampionship) enabledChampionshipsLabels.push('Driver');
if (championships.enableTeamChampionship) enabledChampionshipsLabels.push('Team');
if (championships.enableNationsChampionship) enabledChampionshipsLabels.push('Nations Cup');
if (championships.enableTrophyChampionship) enabledChampionshipsLabels.push('Trophy');
const championshipsSummary =
enabledChampionshipsLabels.length === 0
? 'None enabled yet.'
: enabledChampionshipsLabels.join(', ');
const visibilityLabel = basics.visibility === 'public' ? 'Public' : 'Private';
const gameLabel = 'iRacing';
return (
<Card className="bg-iron-gray/80">
<div className="space-y-6 text-sm text-gray-200">
{/* 1. Basics & visibility */}
<section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
Basics & visibility
</h3>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Name</dt>
<dd className="font-medium text-white">{basics.name || '—'}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Visibility</dt>
<dd>{visibilityLabel}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Game</dt>
<dd>{gameLabel}</dd>
</div>
{basics.description && (
<div className="space-y-1 md:col-span-2">
<dt className="text-xs text-gray-500">Description</dt>
<dd className="text-gray-300">{basics.description}</dd>
</div>
)}
</dl>
</section>
{/* 2. Structure & capacity */}
<section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
Structure & capacity
</h3>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Mode</dt>
<dd>{modeLabel}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Capacity</dt>
<dd>{capacitySentence}</dd>
</div>
</dl>
</section>
{/* 3. Schedule & timings */}
<section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
Schedule & timings
</h3>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Planned rounds</dt>
<dd>{typeof timings.roundsPlanned === 'number' ? timings.roundsPlanned : '—'}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Sessions per weekend</dt>
<dd>{timings.sessionCount ?? '—'}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Practice</dt>
<dd>{formatMinutes(timings.practiceMinutes)}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Qualifying</dt>
<dd>{formatMinutes(timings.qualifyingMinutes)}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Sprint</dt>
<dd>{formatMinutes(timings.sprintRaceMinutes)}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Main race</dt>
<dd>{formatMinutes(timings.mainRaceMinutes)}</dd>
</div>
</dl>
</section>
{/* 4. Scoring & drops */}
<section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
Scoring & drops
</h3>
<dl className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Scoring pattern</dt>
<dd>{scoringPresetName}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Pattern summary</dt>
<dd>{scoringPatternSummary}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs text-gray-500">Drop rule</dt>
<dd>{dropPolicySummary}</dd>
</div>
{scoring.customScoringEnabled && (
<div className="space-y-1">
<dt className="text-xs text-gray-500">Custom scoring</dt>
<dd>Custom scoring flagged</dd>
</div>
)}
</dl>
</section>
{/* 5. Championships */}
<section className="space-y-3">
<h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
Championships
</h3>
<dl className="grid grid-cols-1 gap-4">
<div className="space-y-1">
<dt className="text-xs text-gray-500">Enabled championships</dt>
<dd>{championshipsSummary}</dd>
</div>
</dl>
</section>
</div>
</Card>
);
}

View File

@@ -0,0 +1,489 @@
'use client';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import Button from '@/components/ui/Button';
import PresetCard from '@/components/ui/PresetCard';
interface LeagueScoringSectionProps {
form: LeagueConfigFormModel;
presets: LeagueScoringPresetDTO[];
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
/**
* When true, only render the scoring pattern panel.
*/
patternOnly?: boolean;
/**
* When true, only render the championships panel.
*/
championshipsOnly?: boolean;
}
interface ScoringPatternSectionProps {
scoring: LeagueConfigFormModel['scoring'];
presets: LeagueScoringPresetDTO[];
readOnly?: boolean;
patternError?: string;
onChangePatternId?: (patternId: string) => void;
onToggleCustomScoring?: () => void;
}
interface ChampionshipsSectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
}
export function LeagueScoringSection({
form,
presets,
onChange,
readOnly,
patternOnly,
championshipsOnly,
}: LeagueScoringSectionProps) {
const disabled = readOnly || !onChange;
const updateScoring = (
patch: Partial<LeagueConfigFormModel['scoring']>,
) => {
if (!onChange) return;
onChange({
...form,
scoring: {
...form.scoring,
...patch,
},
});
};
const updateChampionships = (
patch: Partial<LeagueConfigFormModel['championships']>,
) => {
if (!onChange) return;
onChange({
...form,
championships: {
...form.championships,
...patch,
},
});
};
const handleSelectPreset = (presetId: string) => {
if (disabled) return;
updateScoring({
patternId: presetId,
customScoringEnabled: false,
});
};
const handleToggleCustomScoring = () => {
if (disabled) return;
const current = !!form.scoring.customScoringEnabled;
updateScoring({
customScoringEnabled: !current,
});
};
const currentPreset =
presets.find((p) => p.id === form.scoring.patternId) ?? null;
const isTeamsMode = form.structure.mode === 'fixedTeams';
const renderPrimaryChampionshipLabel = () => {
if (!currentPreset) {
return '—';
}
switch (currentPreset.primaryChampionshipType) {
case 'driver':
return 'Driver championship';
case 'team':
return 'Team championship';
case 'nations':
return 'Nations championship';
case 'trophy':
return 'Trophy championship';
default:
return currentPreset.primaryChampionshipType;
}
};
const selectedPreset =
currentPreset ??
(presets.length > 0
? presets.find((p) => p.id === form.scoring.patternId) ?? null
: null);
const patternPanel = (
<ScoringPatternSection
scoring={form.scoring}
presets={presets}
readOnly={readOnly}
onChangePatternId={
!readOnly && onChange ? (id) => handleSelectPreset(id) : undefined
}
onToggleCustomScoring={disabled ? undefined : handleToggleCustomScoring}
/>
);
const championshipsPanel = (
<ChampionshipsSection form={form} onChange={onChange} readOnly={readOnly} />
);
if (patternOnly) {
return <div>{patternPanel}</div>;
}
if (championshipsOnly) {
return <div>{championshipsPanel}</div>;
}
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)]">
{patternPanel}
{championshipsPanel}
</div>
);
}
/**
* Step 4 scoring pattern preset picker used by the wizard.
*/
export function ScoringPatternSection({
scoring,
presets,
readOnly,
patternError,
onChangePatternId,
onToggleCustomScoring,
}: ScoringPatternSectionProps) {
const disabled = readOnly || !onChangePatternId;
const currentPreset =
presets.find((p) => p.id === scoring.patternId) ?? null;
const renderPrimaryLabel = (preset: LeagueScoringPresetDTO) => {
switch (preset.primaryChampionshipType) {
case 'driver':
return 'Driver focus';
case 'team':
return 'Team focus';
case 'nations':
return 'Nations focus';
case 'trophy':
return 'Trophy / cup focus';
default:
return preset.primaryChampionshipType;
}
};
const handleSelect = (presetId: string) => {
if (disabled) return;
onChangePatternId?.(presetId);
};
return (
<section className="space-y-4">
<header className="space-y-1">
<h3 className="text-sm font-semibold text-white">Scoring pattern</h3>
<p className="text-xs text-gray-400">
Pick an overall scoring style; details can evolve later.
</p>
</header>
{presets.length === 0 ? (
<p className="text-sm text-gray-400">No presets available.</p>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{presets.map((preset) => (
<PresetCard
key={preset.id}
title={preset.name}
subtitle={preset.sessionSummary}
primaryTag={renderPrimaryLabel(preset)}
stats={[
{ label: 'Sessions', value: preset.sessionSummary },
{ label: 'Drops', value: preset.dropPolicySummary },
{ label: 'Bonuses', value: preset.bonusSummary },
]}
selected={scoring.patternId === preset.id}
disabled={readOnly}
onSelect={() => handleSelect(preset.id)}
/>
))}
</div>
)}
{patternError && (
<p className="text-xs text-warning-amber">{patternError}</p>
)}
<div className="mt-3 space-y-2 rounded-lg border border-charcoal-outline/70 bg-deep-graphite/70 p-3 text-xs text-gray-300">
<div className="font-semibold text-gray-200">Selected pattern</div>
{currentPreset ? (
<div className="mt-1 space-y-1 text-[11px]">
<div className="inline-flex items-center gap-2">
<span className="inline-flex rounded-full bg-primary-blue/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary-blue">
{currentPreset.name}
</span>
</div>
<p className="text-gray-300">Sessions: {currentPreset.sessionSummary}</p>
<p className="text-gray-300">
Points focus: {currentPreset ? renderPrimaryLabel(currentPreset) : '—'}
</p>
<p className="text-gray-300">
Default drops: {currentPreset.dropPolicySummary}
</p>
</div>
) : (
<p className="text-[11px] text-gray-500">
No pattern selected yet. Pick a card above to define your scoring style.
</p>
)}
</div>
<div className="mt-3 flex items-center justify-between gap-4 rounded-lg border border-charcoal-outline/70 bg-deep-graphite/60 p-3">
<div className="space-y-1">
<p className="text-xs font-medium text-gray-200">
Custom scoring (advanced)
</p>
<p className="text-[11px] text-gray-500">
In this alpha, presets still define the actual scoring; this flag marks intent only.
</p>
</div>
{readOnly ? (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
scoring.customScoringEnabled
? 'bg-primary-blue/15 text-primary-blue'
: 'bg-charcoal-outline/60 text-gray-300'
}`}
>
{scoring.customScoringEnabled
? 'Custom scoring flagged'
: 'Using preset scoring'}
</span>
) : (
<Button
type="button"
variant={scoring.customScoringEnabled ? 'primary' : 'secondary'}
disabled={!onToggleCustomScoring}
onClick={onToggleCustomScoring}
className="shrink-0"
>
{scoring.customScoringEnabled ? 'Custom scoring flagged' : 'Use preset scoring'}
</Button>
)}
</div>
</section>
);
}
/**
* Step 5 championships-only panel used by the wizard.
*/
export function ChampionshipsSection({
form,
onChange,
readOnly,
}: ChampionshipsSectionProps) {
const disabled = readOnly || !onChange;
const isTeamsMode = form.structure.mode === 'fixedTeams';
const updateChampionships = (
patch: Partial<LeagueConfigFormModel['championships']>,
) => {
if (!onChange) return;
onChange({
...form,
championships: {
...form.championships,
...patch,
},
});
};
return (
<section className="space-y-4">
<header className="space-y-1">
<h3 className="text-sm font-semibold text-white">Championships</h3>
<p className="text-xs text-gray-400">
Pick which standings you want to maintain for this season.
</p>
</header>
<div className="space-y-3 text-xs text-gray-300">
{/* Driver championship */}
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3">
<div className="space-y-0.5">
<div className="text-xs font-medium text-gray-100">Driver championship</div>
<p className="text-[11px] text-gray-500">
Per-driver season standings across all points-scoring sessions.
</p>
</div>
<div className="shrink-0">
{readOnly ? (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
form.championships.enableDriverChampionship
? 'bg-primary-blue/15 text-primary-blue'
: 'bg-charcoal-outline/60 text-gray-300'
}`}
>
{form.championships.enableDriverChampionship ? 'On' : 'Off'}
</span>
) : (
<label className="inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
className="h-4 w-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue"
checked={form.championships.enableDriverChampionship}
disabled={disabled}
onChange={(e) =>
updateChampionships({
enableDriverChampionship: e.target.checked,
})
}
/>
<span className="text-gray-200">On</span>
</label>
)}
</div>
</div>
{/* Team championship */}
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3">
<div className="space-y-0.5">
<div className="text-xs font-medium text-gray-100">Team championship</div>
<p className="text-[11px] text-gray-500">
Aggregated season standings for fixed teams.
</p>
{!isTeamsMode && (
<p className="text-[10px] text-gray-500">
Enable team mode in Structure to turn this on.
</p>
)}
</div>
<div className="shrink-0">
{readOnly ? (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
isTeamsMode && form.championships.enableTeamChampionship
? 'bg-primary-blue/15 text-primary-blue'
: 'bg-charcoal-outline/60 text-gray-300'
}`}
>
{isTeamsMode && form.championships.enableTeamChampionship ? 'On' : 'Off'}
</span>
) : (
<label className="inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
className="h-4 w-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue"
checked={
isTeamsMode && form.championships.enableTeamChampionship
}
disabled={disabled || !isTeamsMode}
onChange={(e) =>
updateChampionships({
enableTeamChampionship: e.target.checked,
})
}
/>
<span
className={`text-gray-200 ${
!isTeamsMode ? 'opacity-60' : ''
}`}
>
On
</span>
</label>
)}
</div>
</div>
{/* Nations championship */}
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3">
<div className="space-y-0.5">
<div className="text-xs font-medium text-gray-100">Nations Cup</div>
<p className="text-[11px] text-gray-500">
Standings grouped by drivers' nationality or country flag.
</p>
</div>
<div className="shrink-0">
{readOnly ? (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
form.championships.enableNationsChampionship
? 'bg-primary-blue/15 text-primary-blue'
: 'bg-charcoal-outline/60 text-gray-300'
}`}
>
{form.championships.enableNationsChampionship ? 'On' : 'Off'}
</span>
) : (
<label className="inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
className="h-4 w-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue"
checked={form.championships.enableNationsChampionship}
disabled={disabled}
onChange={(e) =>
updateChampionships({
enableNationsChampionship: e.target.checked,
})
}
/>
<span className="text-gray-200">On</span>
</label>
)}
</div>
</div>
{/* Trophy championship */}
<div className="flex items-start justify-between gap-3 rounded-lg bg-deep-graphite/40 p-3">
<div className="space-y-0.5">
<div className="text-xs font-medium text-gray-100">Trophy / cup</div>
<p className="text-[11px] text-gray-500">
Extra cup-style standings for special categories or invite-only groups.
</p>
</div>
<div className="shrink-0">
{readOnly ? (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
form.championships.enableTrophyChampionship
? 'bg-primary-blue/15 text-primary-blue'
: 'bg-charcoal-outline/60 text-gray-300'
}`}
>
{form.championships.enableTrophyChampionship ? 'On' : 'Off'}
</span>
) : (
<label className="inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
className="h-4 w-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue"
checked={form.championships.enableTrophyChampionship}
disabled={disabled}
onChange={(e) =>
updateChampionships({
enableTrophyChampionship: e.target.checked,
})
}
/>
<span className="text-gray-200">On</span>
</label>
)}
</div>
</div>
<p className="pt-1 text-[10px] text-gray-500">
For this alpha slice, only driver standings are fully calculated, but these toggles express intent for future seasons.
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO';
interface LeagueScoringTabProps {
scoringConfig: LeagueScoringConfigDTO | null;
practiceMinutes?: number;
qualifyingMinutes?: number;
sprintRaceMinutes?: number;
mainRaceMinutes?: number;
}
export default function LeagueScoringTab({
scoringConfig,
practiceMinutes,
qualifyingMinutes,
sprintRaceMinutes,
mainRaceMinutes,
}: LeagueScoringTabProps) {
if (!scoringConfig) {
return (
<div className="text-sm text-gray-400 py-6">
Scoring configuration is not available for this league yet.
</div>
);
}
const primaryChampionship =
scoringConfig.championships.find((c) => c.type === 'driver') ??
scoringConfig.championships[0];
const resolvedPractice = practiceMinutes ?? 20;
const resolvedQualifying = qualifyingMinutes ?? 30;
const resolvedSprint = sprintRaceMinutes;
const resolvedMain = mainRaceMinutes ?? 40;
return (
<div className="space-y-6">
<div className="border-b border-charcoal-outline pb-4 space-y-3">
<h2 className="text-xl font-semibold text-white mb-1">
Scoring overview
</h2>
<p className="text-sm text-gray-400">
{scoringConfig.gameName}{' '}
{scoringConfig.scoringPresetName
? `${scoringConfig.scoringPresetName}`
: '• Custom scoring'}{' '}
{scoringConfig.dropPolicySummary}
</p>
{primaryChampionship && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-200">
Weekend structure & timings
</h3>
<div className="flex flex-wrap gap-2 text-xs">
{primaryChampionship.sessionTypes.map((session) => (
<span
key={session}
className="px-2 py-0.5 rounded-full bg-charcoal-outline/60 text-xs text-gray-200"
>
{session}
</span>
))}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs text-gray-300">
<p>
<span className="text-gray-400">Practice:</span>{' '}
{resolvedPractice ? `${resolvedPractice} min` : '—'}
</p>
<p>
<span className="text-gray-400">Qualifying:</span>{' '}
{resolvedQualifying} min
</p>
<p>
<span className="text-gray-400">Sprint:</span>{' '}
{resolvedSprint ? `${resolvedSprint} min` : '—'}
</p>
<p>
<span className="text-gray-400">Main race:</span>{' '}
{resolvedMain} min
</p>
</div>
</div>
)}
</div>
{scoringConfig.championships.map((championship) => (
<div
key={championship.id}
className="border border-charcoal-outline rounded-lg bg-iron-gray/40 p-4 space-y-4"
>
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-white">
{championship.name}
</h3>
<p className="text-xs uppercase tracking-wide text-gray-500">
{championship.type === 'driver'
? 'Driver championship'
: championship.type === 'team'
? 'Team championship'
: championship.type === 'nations'
? 'Nations championship'
: 'Trophy championship'}
</p>
</div>
{championship.sessionTypes.length > 0 && (
<div className="flex flex-wrap gap-1 justify-end">
{championship.sessionTypes.map((session) => (
<span
key={session}
className="px-2 py-0.5 rounded-full bg-charcoal-outline/60 text-xs text-gray-200"
>
{session}
</span>
))}
</div>
)}
</div>
{championship.pointsPreview.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-400 mb-2">
Points preview (top positions)
</h4>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-charcoal-outline/60">
<th className="text-left py-2 pr-2 text-gray-400">
Session
</th>
<th className="text-left py-2 px-2 text-gray-400">
Position
</th>
<th className="text-left py-2 px-2 text-gray-400">
Points
</th>
</tr>
</thead>
<tbody>
{championship.pointsPreview.map((row, index) => (
<tr
key={`${row.sessionType}-${row.position}-${index}`}
className="border-b border-charcoal-outline/30"
>
<td className="py-1.5 pr-2 text-gray-200">
{row.sessionType}
</td>
<td className="py-1.5 px-2 text-gray-200">
P{row.position}
</td>
<td className="py-1.5 px-2 text-white">
{row.points}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{championship.bonusSummary.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-400 mb-1">
Bonus points
</h4>
<ul className="list-disc list-inside text-xs text-gray-300 space-y-1">
{championship.bonusSummary.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
)}
<div>
<h4 className="text-xs font-semibold text-gray-400 mb-1">
Drop score policy
</h4>
<p className="text-xs text-gray-300">
{championship.dropPolicyDescription}
</p>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,246 @@
'use client';
import Input from '@/components/ui/Input';
import SegmentedControl from '@/components/ui/SegmentedControl';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
interface LeagueStructureSectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
}
export function LeagueStructureSection({
form,
onChange,
readOnly,
}: LeagueStructureSectionProps) {
const disabled = readOnly || !onChange;
const structure = form.structure;
const updateStructure = (
patch: Partial<LeagueConfigFormModel['structure']>,
) => {
if (!onChange) return;
const nextStructure = {
...structure,
...patch,
};
let nextForm: LeagueConfigFormModel = {
...form,
structure: nextStructure,
};
if (nextStructure.mode === 'fixedTeams') {
const maxTeams =
typeof nextStructure.maxTeams === 'number' &&
nextStructure.maxTeams > 0
? nextStructure.maxTeams
: 1;
const driversPerTeam =
typeof nextStructure.driversPerTeam === 'number' &&
nextStructure.driversPerTeam > 0
? nextStructure.driversPerTeam
: 1;
const maxDrivers = maxTeams * driversPerTeam;
nextForm = {
...nextForm,
structure: {
...nextStructure,
maxTeams,
driversPerTeam,
maxDrivers,
},
};
}
if (nextStructure.mode === 'solo') {
nextForm = {
...nextForm,
structure: {
...nextStructure,
maxTeams: undefined,
driversPerTeam: undefined,
},
};
}
onChange(nextForm);
};
const handleModeChange = (mode: 'solo' | 'fixedTeams') => {
if (mode === structure.mode) return;
if (mode === 'solo') {
updateStructure({
mode: 'solo',
maxDrivers: structure.maxDrivers || 24,
maxTeams: undefined,
driversPerTeam: undefined,
});
} else {
const maxTeams = structure.maxTeams ?? 12;
const driversPerTeam = structure.driversPerTeam ?? 2;
updateStructure({
mode: 'fixedTeams',
maxTeams,
driversPerTeam,
maxDrivers: maxTeams * driversPerTeam,
});
}
};
const handleMaxDriversChange = (value: string) => {
const parsed = parseInt(value, 10);
updateStructure({
maxDrivers: Number.isNaN(parsed) ? 0 : parsed,
});
};
const handleMaxTeamsChange = (value: string) => {
const parsed = parseInt(value, 10);
const maxTeams = Number.isNaN(parsed) ? 0 : parsed;
const driversPerTeam = structure.driversPerTeam ?? 2;
updateStructure({
maxTeams,
driversPerTeam,
maxDrivers:
maxTeams > 0 && driversPerTeam > 0
? maxTeams * driversPerTeam
: structure.maxDrivers,
});
};
const handleDriversPerTeamChange = (value: string) => {
const parsed = parseInt(value, 10);
const driversPerTeam = Number.isNaN(parsed) ? 0 : parsed;
const maxTeams = structure.maxTeams ?? 12;
updateStructure({
driversPerTeam,
maxTeams,
maxDrivers:
maxTeams > 0 && driversPerTeam > 0
? maxTeams * driversPerTeam
: structure.maxDrivers,
});
};
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-white">
Step 2 Structure &amp; capacity
</h2>
<div className="space-y-5">
<div>
<span className="block text-sm font-medium text-gray-300 mb-2">
League structure
</span>
<SegmentedControl
options={[
{
value: 'solo',
label: 'Drivers only (Solo)',
description: 'Individual drivers score points.',
},
{
value: 'fixedTeams',
label: 'Teams',
description: 'Teams with fixed drivers per team.',
},
]}
value={structure.mode}
onChange={
disabled
? undefined
: (mode) => handleModeChange(mode as 'solo' | 'fixedTeams')
}
/>
</div>
{structure.mode === 'solo' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300">
Max drivers
</label>
<p className="text-xs text-gray-500">
Typical club leagues use 2030
</p>
<div className="mt-2">
<Input
type="number"
value={structure.maxDrivers ?? 24}
onChange={(e) => handleMaxDriversChange(e.target.value)}
disabled={disabled}
min={1}
max={64}
className="w-28"
/>
</div>
</div>
</div>
)}
{structure.mode === 'fixedTeams' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300">
Max teams
</label>
<p className="text-xs text-gray-500">
Roughly how many teams you expect.
</p>
<div className="mt-2">
<Input
type="number"
value={structure.maxTeams ?? 12}
onChange={(e) => handleMaxTeamsChange(e.target.value)}
disabled={disabled}
min={1}
max={32}
className="w-28"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300">
Drivers per team
</label>
<p className="text-xs text-gray-500">
Common values are 23 drivers.
</p>
<div className="mt-2">
<Input
type="number"
value={structure.driversPerTeam ?? 2}
onChange={(e) => handleDriversPerTeamChange(e.target.value)}
disabled={disabled}
min={1}
max={6}
className="w-28"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300">
Max drivers (derived)
</label>
<p className="text-xs text-gray-500">
Calculated as teams × drivers per team.
</p>
<div className="mt-2 max-w-[7rem]">
<Input
type="number"
value={structure.maxDrivers ?? 0}
disabled
/>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,751 @@
'use client';
import { useEffect, useState } from 'react';
import type {
LeagueConfigFormModel,
LeagueSchedulePreviewDTO,
} from '@gridpilot/racing/application';
import type { Weekday } from '@gridpilot/racing/domain/value-objects/Weekday';
import Heading from '@/components/ui/Heading';
import Input from '@/components/ui/Input';
import SegmentedControl from '@/components/ui/SegmentedControl';
type RecurrenceStrategy = NonNullable<LeagueConfigFormModel['timings']>['recurrenceStrategy'];
interface LeagueTimingsSectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
errors?: {
qualifyingMinutes?: string;
mainRaceMinutes?: string;
roundsPlanned?: string;
};
/**
* Optional override for the section heading.
* When omitted, defaults to "Schedule & timings".
*/
title?: string;
/**
* Local wizard-only weekend template identifier.
*/
weekendTemplate?: string;
/**
* Callback when the weekend template selection changes.
*/
onWeekendTemplateChange?: (template: string) => void;
}
export function LeagueTimingsSection({
form,
onChange,
readOnly,
errors,
title,
weekendTemplate,
onWeekendTemplateChange,
}: LeagueTimingsSectionProps) {
const disabled = readOnly || !onChange;
const timings = form.timings;
const [schedulePreview, setSchedulePreview] =
useState<LeagueSchedulePreviewDTO | null>(null);
const [schedulePreviewError, setSchedulePreviewError] = useState<string | null>(
null,
);
const [isSchedulePreviewLoading, setIsSchedulePreviewLoading] = useState(false);
const updateTimings = (
patch: Partial<NonNullable<LeagueConfigFormModel['timings']>>,
) => {
if (!onChange) return;
onChange({
...form,
timings: {
...timings,
...patch,
},
});
};
const handleRoundsChange = (value: string) => {
if (!onChange) return;
const trimmed = value.trim();
if (trimmed === '') {
updateTimings({ roundsPlanned: undefined });
return;
}
const parsed = parseInt(trimmed, 10);
updateTimings({
roundsPlanned: Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed,
});
};
const showSprint =
form.scoring.patternId === 'sprint-main-driver' ||
(typeof timings.sprintRaceMinutes === 'number' && timings.sprintRaceMinutes > 0);
const recurrenceStrategy: RecurrenceStrategy =
timings.recurrenceStrategy ?? 'weekly';
const weekdays: Weekday[] = (timings.weekdays ?? []) as Weekday[];
const handleWeekdayToggle = (day: Weekday) => {
const current = new Set(weekdays);
if (current.has(day)) {
current.delete(day);
} else {
current.add(day);
}
updateTimings({ weekdays: Array.from(current).sort() });
};
const handleRecurrenceChange = (value: string) => {
updateTimings({
recurrenceStrategy: value as RecurrenceStrategy,
});
};
const requiresWeekdaySelection =
(recurrenceStrategy === 'weekly' || recurrenceStrategy === 'everyNWeeks') &&
weekdays.length === 0;
useEffect(() => {
if (!timings) return;
const {
seasonStartDate,
raceStartTime,
timezoneId,
recurrenceStrategy: currentStrategy,
intervalWeeks,
weekdays: currentWeekdays,
monthlyOrdinal,
monthlyWeekday,
roundsPlanned,
} = timings;
const hasCoreFields =
!!seasonStartDate &&
!!raceStartTime &&
!!timezoneId &&
!!currentStrategy &&
typeof roundsPlanned === 'number' &&
roundsPlanned > 0;
if (!hasCoreFields) {
setSchedulePreview(null);
setSchedulePreviewError(null);
setIsSchedulePreviewLoading(false);
return;
}
if (
(currentStrategy === 'weekly' || currentStrategy === 'everyNWeeks') &&
(!currentWeekdays || currentWeekdays.length === 0)
) {
setSchedulePreview(null);
setSchedulePreviewError(null);
setIsSchedulePreviewLoading(false);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(async () => {
try {
setIsSchedulePreviewLoading(true);
setSchedulePreviewError(null);
const payload = {
seasonStartDate,
raceStartTime,
timezoneId,
recurrenceStrategy: currentStrategy,
intervalWeeks,
weekdays: currentWeekdays,
monthlyOrdinal,
monthlyWeekday,
plannedRounds: roundsPlanned,
};
const response = await fetch('/api/leagues/schedule-preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!response.ok) {
const message =
response.status === 400
? 'Could not compute schedule with current values.'
: 'Failed to load schedule preview.';
setSchedulePreviewError(message);
return;
}
const data = (await response.json()) as LeagueSchedulePreviewDTO;
setSchedulePreview(data);
} catch (err) {
if ((err as any).name === 'AbortError') {
return;
}
setSchedulePreviewError('Could not compute schedule with current values.');
} finally {
setIsSchedulePreviewLoading(false);
}
}, 400);
return () => {
clearTimeout(timeoutId);
controller.abort();
};
}, [
timings?.seasonStartDate,
timings?.raceStartTime,
timings?.timezoneId,
timings?.recurrenceStrategy,
timings?.intervalWeeks,
timings?.monthlyOrdinal,
timings?.monthlyWeekday,
timings?.roundsPlanned,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(timings?.weekdays ?? []),
]);
if (disabled) {
return (
<div className="space-y-4">
<Heading level={3} className="text-lg font-semibold text-white">
{title ?? 'Schedule & timings'}
</Heading>
<div className="space-y-3 text-sm text-gray-300">
<div>
<span className="font-medium text-gray-200">Planned rounds:</span>{' '}
<span>{timings.roundsPlanned ?? '—'}</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<span className="font-medium text-gray-200">Qualifying:</span>{' '}
<span>{timings.qualifyingMinutes} min</span>
</div>
<div>
<span className="font-medium text-gray-200">Main race:</span>{' '}
<span>{timings.mainRaceMinutes} min</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<span className="font-medium text-gray-200">Practice:</span>{' '}
<span>
{typeof timings.practiceMinutes === 'number'
? `${timings.practiceMinutes} min`
: '—'}
</span>
</div>
{showSprint && (
<div>
<span className="font-medium text-gray-200">Sprint:</span>{' '}
<span>
{typeof timings.sprintRaceMinutes === 'number'
? `${timings.sprintRaceMinutes} min`
: '—'}
</span>
</div>
)}
</div>
<div>
<span className="font-medium text-gray-200">Sessions per weekend:</span>{' '}
<span>{timings.sessionCount}</span>
</div>
<p className="text-xs text-gray-500">
Used for planning and hints; alpha-only and not yet fully wired into
race scheduling.
</p>
</div>
</div>
);
}
const handleNumericMinutesChange = (
field:
| 'practiceMinutes'
| 'qualifyingMinutes'
| 'sprintRaceMinutes'
| 'mainRaceMinutes',
raw: string,
) => {
if (!onChange) return;
const trimmed = raw.trim();
if (trimmed === '') {
updateTimings({ [field]: undefined } as Partial<
NonNullable<LeagueConfigFormModel['timings']>
>);
return;
}
const parsed = parseInt(trimmed, 10);
updateTimings({
[field]:
Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed,
} as Partial<NonNullable<LeagueConfigFormModel['timings']>>);
};
const handleSessionCountChange = (raw: string) => {
const trimmed = raw.trim();
if (trimmed === '') {
updateTimings({ sessionCount: 1 });
return;
}
const parsed = parseInt(trimmed, 10);
updateTimings({
sessionCount: Number.isNaN(parsed) || parsed <= 0 ? 1 : parsed,
});
};
const weekendTemplateValue = weekendTemplate ?? '';
return (
<div className="space-y-6">
<Heading level={3} className="text-lg font-semibold text-white">
{title ?? 'Schedule & timings'}
</Heading>
<div className="space-y-6">
{/* Season length block */}
<section className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Season length
</h4>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Planned rounds
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.roundsPlanned === 'number'
? String(timings.roundsPlanned)
: ''
}
onChange={(e) => handleRoundsChange(e.target.value)}
min={1}
error={!!errors?.roundsPlanned}
errorMessage={errors?.roundsPlanned}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Used for planning and drop hints; can be approximate.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Sessions per weekend
</label>
<div className="w-24">
<Input
type="number"
value={String(timings.sessionCount)}
onChange={(e) => handleSessionCountChange(e.target.value)}
min={1}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Typically 1 for feature-only; 2 for sprint + feature.
</p>
</div>
</div>
</section>
{/* Race schedule block */}
<section className="space-y-4">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Race schedule
</h4>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Season start date
</label>
<div className="max-w-xs">
<Input
type="date"
value={timings.seasonStartDate ?? ''}
onChange={(e) =>
updateTimings({ seasonStartDate: e.target.value || undefined })
}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Round 1 will use this date; later rounds follow your pattern.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Race start time
</label>
<div className="max-w-xs">
<Input
type="time"
value={timings.raceStartTime ?? ''}
onChange={(e) =>
updateTimings({ raceStartTime: e.target.value || undefined })
}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Local time in your league's timezone.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Timezone
</label>
<div className="max-w-xs">
<select
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6"
value={timings.timezoneId ?? 'Europe/Berlin'}
onChange={(e) =>
updateTimings({ timezoneId: e.target.value || undefined })
}
>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/London">Europe/London</option>
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
</select>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] items-start">
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Cadence
</label>
<SegmentedControl
options={[
{ value: 'weekly', label: 'Weekly' },
{ value: 'everyNWeeks', label: 'Every N weeks' },
{
value: 'monthlyNthWeekday',
label: 'Monthly (beta)',
disabled: true,
},
]}
value={recurrenceStrategy}
onChange={handleRecurrenceChange}
/>
</div>
{recurrenceStrategy === 'everyNWeeks' && (
<div className="flex items-center gap-2 text-sm text-gray-300">
<span>Every</span>
<div className="w-20">
<Input
type="number"
min={1}
value={
typeof timings.intervalWeeks === 'number'
? String(timings.intervalWeeks)
: '2'
}
onChange={(e) => {
const raw = e.target.value.trim();
if (raw === '') {
updateTimings({ intervalWeeks: undefined });
return;
}
const parsed = parseInt(raw, 10);
updateTimings({
intervalWeeks:
Number.isNaN(parsed) || parsed <= 0 ? 2 : parsed,
});
}}
/>
</div>
<span>weeks</span>
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="block text-sm font-medium text-gray-300">
Race days in a week
</label>
{requiresWeekdaySelection && (
<span className="text-[11px] text-warning-amber">
Select at least one weekday.
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as Weekday[]).map(
(day) => {
const isActive = weekdays.includes(day);
return (
<button
key={day}
type="button"
onClick={() => handleWeekdayToggle(day)}
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
isActive
? 'bg-primary-blue text-white border-primary-blue'
: 'bg-iron-gray/80 text-gray-300 border-charcoal-outline hover:bg-charcoal-outline/80 hover:text-white'
}`}
>
{day}
</button>
);
},
)}
</div>
</div>
</div>
<div className="space-y-2 rounded-md border border-charcoal-outline bg-iron-gray/40 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-medium text-gray-200">
Schedule summary
</p>
<p className="text-xs text-gray-400">
{schedulePreview?.summary ??
'Set a start date, time, and at least one weekday to preview the schedule.'}
</p>
</div>
{isSchedulePreviewLoading && (
<span className="text-[11px] text-gray-400">Updating…</span>
)}
</div>
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-gray-300">
Schedule preview
</p>
<p className="text-[11px] text-gray-500">
First few rounds with your current settings.
</p>
</div>
{schedulePreviewError && (
<p className="text-[11px] text-warning-amber">
{schedulePreviewError}
</p>
)}
{!schedulePreview && !schedulePreviewError && (
<p className="text-[11px] text-gray-500">
Adjust the fields above to see a preview of your calendar.
</p>
)}
{schedulePreview && (
<div className="mt-1 space-y-1.5 text-xs text-gray-200">
{schedulePreview.rounds.map((round) => {
const date = new Date(round.scheduledAt);
const dateStr = date.toLocaleDateString(undefined, {
weekday: 'short',
day: 'numeric',
month: 'short',
});
const timeStr = date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
return (
<div
key={round.roundNumber}
className="flex items-center justify-between gap-2"
>
<span className="text-gray-300">
Round {round.roundNumber}
</span>
<span className="text-gray-200">
{dateStr}, {timeStr}{' '}
<span className="text-gray-500">
{round.timezoneId}
</span>
</span>
</div>
);
})}
{typeof timings.roundsPlanned === 'number' &&
timings.roundsPlanned > schedulePreview.rounds.length && (
<p className="pt-1 text-[11px] text-gray-500">
+
{timings.roundsPlanned - schedulePreview.rounds.length}{' '}
more rounds scheduled.
</p>
)}
</div>
)}
</div>
</div>
</section>
{/* Weekend template block */}
<section className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Weekend template
</h4>
<p className="text-xs text-gray-500">
Pick a typical weekend; you can fine-tune durations below.
</p>
<SegmentedControl
options={[
{ value: 'feature', label: 'Feature only' },
{ value: 'sprintFeature', label: 'Sprint + feature' },
{ value: 'endurance', label: 'Endurance' },
]}
value={weekendTemplateValue}
onChange={onWeekendTemplateChange}
/>
<p className="text-[11px] text-gray-500">
Templates set starting values only; you can override any number.
</p>
</section>
{/* Session durations block */}
<section className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Session durations
</h4>
<p className="text-xs text-gray-500">
Rough lengths for each session type; used for planning only.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Practice duration (optional)
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.practiceMinutes === 'number' &&
timings.practiceMinutes > 0
? String(timings.practiceMinutes)
: ''
}
onChange={(e) =>
handleNumericMinutesChange(
'practiceMinutes',
e.target.value,
)
}
min={0}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Set to 0 or leave empty if you dont plan dedicated practice.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Qualifying duration
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.qualifyingMinutes === 'number' &&
timings.qualifyingMinutes > 0
? String(timings.qualifyingMinutes)
: ''
}
onChange={(e) =>
handleNumericMinutesChange(
'qualifyingMinutes',
e.target.value,
)
}
min={5}
error={!!errors?.qualifyingMinutes}
errorMessage={errors?.qualifyingMinutes}
/>
</div>
</div>
{showSprint && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Sprint duration
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.sprintRaceMinutes === 'number' &&
timings.sprintRaceMinutes > 0
? String(timings.sprintRaceMinutes)
: ''
}
onChange={(e) =>
handleNumericMinutesChange(
'sprintRaceMinutes',
e.target.value,
)
}
min={0}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Only shown when your scoring pattern includes a sprint race.
</p>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Main race duration
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.mainRaceMinutes === 'number' &&
timings.mainRaceMinutes > 0
? String(timings.mainRaceMinutes)
: ''
}
onChange={(e) =>
handleNumericMinutesChange(
'mainRaceMinutes',
e.target.value,
)
}
min={10}
error={!!errors?.mainRaceMinutes}
errorMessage={errors?.mainRaceMinutes}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Approximate length of your main race.
</p>
</div>
</div>
</section>
</div>
</div>
);
}

View File

@@ -9,7 +9,7 @@ interface CardProps {
export default function Card({ children, className = '', onClick }: CardProps) { export default function Card({ children, className = '', onClick }: CardProps) {
return ( return (
<div <div
className={`rounded-lg bg-iron-gray p-6 shadow-card border border-charcoal-outline hover:shadow-glow transition-shadow duration-200 ${className}`} className={`rounded-lg bg-iron-gray p-6 shadow-card border border-charcoal-outline duration-200 ${className}`}
onClick={onClick} onClick={onClick}
> >
{children} {children}

View File

@@ -0,0 +1,71 @@
'use client';
import Input from '@/components/ui/Input';
interface DurationFieldProps {
label: string;
value: number | '';
onChange: (value: number | '') => void;
helperText?: string;
required?: boolean;
disabled?: boolean;
unit?: 'minutes' | 'laps';
error?: string;
}
export default function DurationField({
label,
value,
onChange,
helperText,
required,
disabled,
unit = 'minutes',
error,
}: DurationFieldProps) {
const handleChange = (raw: string) => {
if (raw.trim() === '') {
onChange('');
return;
}
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
onChange('');
return;
}
onChange(parsed);
};
const unitLabel = unit === 'laps' ? 'laps' : 'min';
return (
<div className="space-y-1">
<label className="block text-sm font-medium text-gray-300">
{label}
{required && <span className="text-warning-amber ml-1">*</span>}
</label>
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
type="number"
value={value === '' ? '' : String(value)}
onChange={(e) => handleChange(e.target.value)}
disabled={disabled}
min={1}
className="pr-16"
error={!!error}
/>
</div>
<span className="text-xs text-gray-400 -ml-14">{unitLabel}</span>
</div>
{helperText && (
<p className="text-xs text-gray-500">{helperText}</p>
)}
{error && (
<p className="text-xs text-warning-amber mt-1">{error}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import type { MouseEventHandler, ReactNode } from 'react';
import Card from './Card';
interface PresetCardStat {
label: string;
value: string;
}
export interface PresetCardProps {
title: string;
subtitle?: string;
primaryTag?: string;
description?: string;
stats?: PresetCardStat[];
selected?: boolean;
disabled?: boolean;
onSelect?: () => void;
className?: string;
children?: ReactNode;
}
export default function PresetCard({
title,
subtitle,
primaryTag,
description,
stats,
selected,
disabled,
onSelect,
className = '',
children,
}: PresetCardProps) {
const isInteractive = typeof onSelect === 'function' && !disabled;
const handleClick: MouseEventHandler<HTMLButtonElement | HTMLDivElement> = (event) => {
if (!isInteractive) {
return;
}
event.preventDefault();
onSelect?.();
};
const baseBorder = selected ? 'border-primary-blue' : 'border-charcoal-outline';
const baseBg = selected ? 'bg-primary-blue/10' : 'bg-iron-gray';
const baseRing = selected ? 'ring-2 ring-primary-blue/40' : '';
const disabledClasses = disabled ? 'opacity-60 cursor-not-allowed' : '';
const hoverClasses = isInteractive && !disabled ? 'hover:bg-iron-gray/80 hover:scale-[1.01]' : '';
const content = (
<div className="flex h-full flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{title}</div>
{subtitle && (
<div className="mt-0.5 text-xs text-gray-400">{subtitle}</div>
)}
</div>
<div className="flex flex-col items-end gap-1">
{primaryTag && (
<span className="inline-flex rounded-full bg-primary-blue/15 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary-blue">
{primaryTag}
</span>
)}
{selected && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary-blue/10 px-2 py-0.5 text-[10px] font-medium text-primary-blue">
<span className="h-1.5 w-1.5 rounded-full bg-primary-blue" />
Selected
</span>
)}
</div>
</div>
{description && (
<p className="text-xs text-gray-300">{description}</p>
)}
{children}
{stats && stats.length > 0 && (
<div className="mt-1 border-t border-charcoal-outline/70 pt-2">
<dl className="grid grid-cols-1 gap-2 text-[11px] text-gray-400 sm:grid-cols-3">
{stats.map((stat) => (
<div key={stat.label} className="space-y-0.5">
<dt className="font-medium text-gray-500">{stat.label}</dt>
<dd className="text-xs text-gray-200">{stat.value}</dd>
</div>
))}
</dl>
</div>
)}
</div>
);
const commonClasses = `${baseBorder} ${baseBg} ${baseRing} ${hoverClasses} ${disabledClasses} ${className}`;
if (isInteractive) {
return (
<button
type="button"
onClick={handleClick as MouseEventHandler<HTMLButtonElement>}
disabled={disabled}
className={`group block w-full rounded-lg text-left text-sm shadow-card outline-none transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-blue ${commonClasses}`}
>
<div className="p-4">
{content}
</div>
</button>
);
}
return (
<Card
className={commonClasses}
onClick={handleClick as MouseEventHandler<HTMLDivElement>}
>
{content}
</Card>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import Input from '@/components/ui/Input';
interface RangeFieldProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
helperText?: string;
error?: string;
disabled?: boolean;
/**
* Optional unit label, defaults to "min".
*/
unitLabel?: string;
/**
* Optional override for the right-hand range hint.
*/
rangeHint?: string;
}
export default function RangeField({
label,
value,
min,
max,
step = 1,
onChange,
helperText,
error,
disabled,
unitLabel = 'min',
rangeHint,
}: RangeFieldProps) {
const clampedValue = Number.isFinite(value)
? Math.min(Math.max(value, min), max)
: min;
const handleSliderChange = (raw: string) => {
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed)) {
return;
}
const next = Math.min(Math.max(parsed, min), max);
onChange(next);
};
const handleNumberChange = (raw: string) => {
if (raw.trim() === '') {
// Allow the field to clear without jumping the slider;
// keep the previous value until the user types a number.
return;
}
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed)) {
return;
}
const next = Math.min(Math.max(parsed, min), max);
onChange(next);
};
const rangePercent =
((clampedValue - min) / Math.max(max - min, 1)) * 100;
const effectiveRangeHint =
rangeHint ??
(min === 0
? `Up to ${max} ${unitLabel}`
: `${min}${max} ${unitLabel}`);
return (
<div className="space-y-2">
<div className="flex items-baseline justify-between gap-2">
<label className="block text-sm font-medium text-gray-300">
{label}
</label>
<p className="text-[11px] text-gray-500">
{effectiveRangeHint}
</p>
</div>
<div className="space-y-2">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 right-0 rounded-full bg-charcoal-outline/60" />
<div
className="pointer-events-none absolute inset-y-0 left-0 rounded-full bg-primary-blue"
style={{ width: `${rangePercent}%` }}
/>
<input
type="range"
min={min}
max={max}
step={step}
value={clampedValue}
onChange={(e) => handleSliderChange(e.target.value)}
disabled={disabled}
className="relative z-10 h-2 w-full appearance-none bg-transparent focus:outline-none accent-primary-blue"
/>
</div>
<div className="flex items-center gap-2">
<div className="max-w-[96px]">
<Input
type="number"
value={Number.isFinite(value) ? String(clampedValue) : ''}
onChange={(e) => handleNumberChange(e.target.value)}
min={min}
max={max}
step={step}
disabled={disabled}
className="px-3 py-2 text-sm"
error={!!error}
/>
</div>
<span className="text-xs text-gray-400">{unitLabel}</span>
</div>
</div>
{helperText && (
<p className="text-xs text-gray-500">{helperText}</p>
)}
{error && (
<p className="text-xs text-warning-amber mt-1">{error}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import { ButtonHTMLAttributes } from 'react';
interface SegmentedControlOption {
value: string;
label: string;
description?: string;
disabled?: boolean;
}
interface SegmentedControlProps {
options: SegmentedControlOption[];
value: string;
onChange?: (value: string) => void;
}
export default function SegmentedControl({
options,
value,
onChange,
}: SegmentedControlProps) {
const handleSelect = (optionValue: string, optionDisabled?: boolean) => {
if (!onChange || optionDisabled) return;
if (optionValue === value) return;
onChange(optionValue);
};
return (
<div className="inline-flex w-full flex-wrap gap-2 rounded-full bg-iron-gray/60 px-1 py-1">
{options.map((option) => {
const isSelected = option.value === value;
const baseClasses =
'flex-1 min-w-[140px] px-3 py-1.5 text-xs font-medium rounded-full transition-colors text-left';
const selectedClasses = isSelected
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80';
const disabledClasses = option.disabled
? 'opacity-50 cursor-not-allowed hover:bg-transparent hover:text-gray-300'
: '';
const buttonProps: ButtonHTMLAttributes<HTMLButtonElement> = {
type: 'button',
onClick: () => handleSelect(option.value, option.disabled),
'aria-pressed': isSelected,
disabled: option.disabled,
};
return (
<button
key={option.value}
{...buttonProps}
className={`${baseClasses} ${selectedClasses} ${disabledClasses}`}
>
<div className="flex flex-col items-start">
<span>{option.label}</span>
{option.description && (
<span className="mt-0.5 text-[10px] text-gray-400">
{option.description}
</span>
)}
</div>
</button>
);
})}
</div>
);
}

View File

@@ -10,6 +10,8 @@ import { League } from '@gridpilot/racing/domain/entities/League';
import { Race } from '@gridpilot/racing/domain/entities/Race'; import { Race } from '@gridpilot/racing/domain/entities/Race';
import { Result } from '@gridpilot/racing/domain/entities/Result'; import { Result } from '@gridpilot/racing/domain/entities/Result';
import { Standing } from '@gridpilot/racing/domain/entities/Standing'; import { Standing } from '@gridpilot/racing/domain/entities/Standing';
import { Game } from '@gridpilot/racing/domain/entities/Game';
import { Season } from '@gridpilot/racing/domain/entities/Season';
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository'; import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
@@ -17,6 +19,9 @@ import type { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRac
import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository'; import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository';
import type { IStandingRepository } from '@gridpilot/racing/domain/repositories/IStandingRepository'; import type { IStandingRepository } from '@gridpilot/racing/domain/repositories/IStandingRepository';
import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository'; import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository';
import type { IGameRepository } from '@gridpilot/racing/domain/repositories/IGameRepository';
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository';
import type { import type {
ITeamRepository, ITeamRepository,
ITeamMembershipRepository, ITeamMembershipRepository,
@@ -34,6 +39,13 @@ import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/reposit
import { InMemoryResultRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryResultRepository'; import { InMemoryResultRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryResultRepository';
import { InMemoryStandingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryStandingRepository'; import { InMemoryStandingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryStandingRepository';
import { InMemoryPenaltyRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryPenaltyRepository'; import { InMemoryPenaltyRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryPenaltyRepository';
import {
InMemoryGameRepository,
InMemorySeasonRepository,
InMemoryLeagueScoringConfigRepository,
getLeagueScoringPresetById,
} from '@gridpilot/racing/infrastructure/repositories/InMemoryScoringRepositories';
import { InMemoryLeagueScoringPresetProvider } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueScoringPresetProvider';
import { InMemoryTeamRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamRepository'; import { InMemoryTeamRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamRepository';
import { InMemoryTeamMembershipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamMembershipRepository'; import { InMemoryTeamMembershipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamMembershipRepository';
import { InMemoryRaceRegistrationRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRegistrationRepository'; import { InMemoryRaceRegistrationRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRegistrationRepository';
@@ -58,13 +70,28 @@ import {
GetLeagueStandingsQuery, GetLeagueStandingsQuery,
GetLeagueDriverSeasonStatsQuery, GetLeagueDriverSeasonStatsQuery,
GetAllLeaguesWithCapacityQuery, GetAllLeaguesWithCapacityQuery,
GetAllLeaguesWithCapacityAndScoringQuery,
ListLeagueScoringPresetsQuery,
GetLeagueScoringConfigQuery,
CreateLeagueWithSeasonAndScoringUseCase,
GetLeagueFullConfigQuery,
} from '@gridpilot/racing/application'; } from '@gridpilot/racing/application';
import { createStaticRacingSeed, type RacingSeedData } from '@gridpilot/testing-support'; import {
createStaticRacingSeed,
type RacingSeedData,
getDemoLeagueArchetypeByName,
} from '@gridpilot/testing-support';
import type {
LeagueScheduleDTO,
LeagueSchedulePreviewDTO,
} from '@gridpilot/racing/application';
import { PreviewLeagueScheduleQuery } from '@gridpilot/racing/application';
import { import {
InMemoryFeedRepository, InMemoryFeedRepository,
InMemorySocialGraphRepository, InMemorySocialGraphRepository,
} from '@gridpilot/social/infrastructure/inmemory/InMemorySocialAndFeed'; } from '@gridpilot/social/infrastructure/inmemory/InMemorySocialAndFeed';
import { DemoImageServiceAdapter } from '@gridpilot/demo-infrastructure'; import { DemoImageServiceAdapter } from '@gridpilot/demo-infrastructure';
import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
/** /**
* Seed data for development * Seed data for development
@@ -138,6 +165,10 @@ class DIContainer {
private _teamMembershipRepository: ITeamMembershipRepository; private _teamMembershipRepository: ITeamMembershipRepository;
private _raceRegistrationRepository: IRaceRegistrationRepository; private _raceRegistrationRepository: IRaceRegistrationRepository;
private _leagueMembershipRepository: ILeagueMembershipRepository; private _leagueMembershipRepository: ILeagueMembershipRepository;
private _gameRepository: IGameRepository;
private _seasonRepository: ISeasonRepository;
private _leagueScoringConfigRepository: ILeagueScoringConfigRepository;
private _leagueScoringPresetProvider: LeagueScoringPresetProvider;
private _feedRepository: IFeedRepository; private _feedRepository: IFeedRepository;
private _socialRepository: ISocialGraphRepository; private _socialRepository: ISocialGraphRepository;
private _imageService: ImageServicePort; private _imageService: ImageServicePort;
@@ -151,6 +182,13 @@ class DIContainer {
private _getLeagueStandingsQuery: GetLeagueStandingsQuery; private _getLeagueStandingsQuery: GetLeagueStandingsQuery;
private _getLeagueDriverSeasonStatsQuery: GetLeagueDriverSeasonStatsQuery; private _getLeagueDriverSeasonStatsQuery: GetLeagueDriverSeasonStatsQuery;
private _getAllLeaguesWithCapacityQuery: GetAllLeaguesWithCapacityQuery; private _getAllLeaguesWithCapacityQuery: GetAllLeaguesWithCapacityQuery;
private _getAllLeaguesWithCapacityAndScoringQuery: GetAllLeaguesWithCapacityAndScoringQuery;
private _listLeagueScoringPresetsQuery: ListLeagueScoringPresetsQuery;
private _getLeagueScoringConfigQuery: GetLeagueScoringConfigQuery;
private _createLeagueWithSeasonAndScoringUseCase: CreateLeagueWithSeasonAndScoringUseCase;
private _getLeagueFullConfigQuery: GetLeagueFullConfigQuery;
// Placeholder for future schedule preview wiring
private _previewLeagueScheduleQuery: PreviewLeagueScheduleQuery;
private _createTeamUseCase: CreateTeamUseCase; private _createTeamUseCase: CreateTeamUseCase;
private _joinTeamUseCase: JoinTeamUseCase; private _joinTeamUseCase: JoinTeamUseCase;
@@ -190,10 +228,51 @@ class DIContainer {
// Race registrations (start empty; populated via use-cases) // Race registrations (start empty; populated via use-cases)
this._raceRegistrationRepository = new InMemoryRaceRegistrationRepository(); this._raceRegistrationRepository = new InMemoryRaceRegistrationRepository();
// Penalties (seeded in-memory adapter) // Penalties (seeded in-memory adapter)
this._penaltyRepository = new InMemoryPenaltyRepository(); this._penaltyRepository = new InMemoryPenaltyRepository();
// Scoring preset provider and seeded game/season/scoring config repositories
this._leagueScoringPresetProvider = new InMemoryLeagueScoringPresetProvider();
const game = Game.create({ id: 'iracing', name: 'iRacing' });
const seededSeasons: Season[] = [];
const seededScoringConfigs = [];
for (const league of seedData.leagues) {
const archetype = getDemoLeagueArchetypeByName(league.name);
if (!archetype) continue;
const season = Season.create({
id: `season-${league.id}-demo`,
leagueId: league.id,
gameId: game.id,
name: `${league.name} Demo Season`,
year: new Date().getFullYear(),
order: 1,
status: 'active',
startDate: new Date(),
endDate: new Date(),
});
seededSeasons.push(season);
const infraPreset = getLeagueScoringPresetById(
archetype.scoringPresetId,
);
if (!infraPreset) {
// If a preset is missing, skip scoring config for this league in alpha seed.
continue;
}
const config = infraPreset.createConfig({ seasonId: season.id });
seededScoringConfigs.push(config);
}
this._gameRepository = new InMemoryGameRepository([game]);
this._seasonRepository = new InMemorySeasonRepository(seededSeasons);
this._leagueScoringConfigRepository =
new InMemoryLeagueScoringConfigRepository(seededScoringConfigs);
// League memberships seeded from static memberships with guaranteed owner roles // League memberships seeded from static memberships with guaranteed owner roles
const seededMemberships: LeagueMembership[] = seedData.memberships.map((m) => ({ const seededMemberships: LeagueMembership[] = seedData.memberships.map((m) => ({
leagueId: m.leagueId, leagueId: m.leagueId,
@@ -371,6 +450,46 @@ class DIContainer {
this._leagueMembershipRepository, this._leagueMembershipRepository,
); );
this._getAllLeaguesWithCapacityAndScoringQuery =
new GetAllLeaguesWithCapacityAndScoringQuery(
this._leagueRepository,
this._leagueMembershipRepository,
this._seasonRepository,
this._leagueScoringConfigRepository,
this._gameRepository,
this._leagueScoringPresetProvider,
);
this._listLeagueScoringPresetsQuery = new ListLeagueScoringPresetsQuery(
this._leagueScoringPresetProvider,
);
this._getLeagueScoringConfigQuery = new GetLeagueScoringConfigQuery(
this._leagueRepository,
this._seasonRepository,
this._leagueScoringConfigRepository,
this._gameRepository,
this._leagueScoringPresetProvider,
);
this._getLeagueFullConfigQuery = new GetLeagueFullConfigQuery(
this._leagueRepository,
this._seasonRepository,
this._leagueScoringConfigRepository,
this._gameRepository,
);
this._createLeagueWithSeasonAndScoringUseCase =
new CreateLeagueWithSeasonAndScoringUseCase(
this._leagueRepository,
this._seasonRepository,
this._leagueScoringConfigRepository,
this._leagueScoringPresetProvider,
);
// Schedule preview query (used by league creation wizard step 3)
this._previewLeagueScheduleQuery = new PreviewLeagueScheduleQuery();
this._createTeamUseCase = new CreateTeamUseCase( this._createTeamUseCase = new CreateTeamUseCase(
this._teamRepository, this._teamRepository,
this._teamMembershipRepository, this._teamMembershipRepository,
@@ -464,6 +583,22 @@ class DIContainer {
return this._leagueMembershipRepository; return this._leagueMembershipRepository;
} }
get gameRepository(): IGameRepository {
return this._gameRepository;
}
get seasonRepository(): ISeasonRepository {
return this._seasonRepository;
}
get leagueScoringConfigRepository(): ILeagueScoringConfigRepository {
return this._leagueScoringConfigRepository;
}
get leagueScoringPresetProvider(): LeagueScoringPresetProvider {
return this._leagueScoringPresetProvider;
}
get joinLeagueUseCase(): JoinLeagueUseCase { get joinLeagueUseCase(): JoinLeagueUseCase {
return this._joinLeagueUseCase; return this._joinLeagueUseCase;
} }
@@ -496,6 +631,31 @@ class DIContainer {
return this._getAllLeaguesWithCapacityQuery; return this._getAllLeaguesWithCapacityQuery;
} }
get getAllLeaguesWithCapacityAndScoringQuery(): GetAllLeaguesWithCapacityAndScoringQuery {
return this._getAllLeaguesWithCapacityAndScoringQuery;
}
get listLeagueScoringPresetsQuery(): ListLeagueScoringPresetsQuery {
return this._listLeagueScoringPresetsQuery;
}
get getLeagueScoringConfigQuery(): GetLeagueScoringConfigQuery {
return this._getLeagueScoringConfigQuery;
}
get getLeagueFullConfigQuery(): GetLeagueFullConfigQuery {
return this._getLeagueFullConfigQuery;
}
// Placeholder accessor for schedule preview; API route/UI can call this later.
get previewLeagueScheduleQuery(): PreviewLeagueScheduleQuery {
return this._previewLeagueScheduleQuery;
}
get createLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase {
return this._createLeagueWithSeasonAndScoringUseCase;
}
get createTeamUseCase(): CreateTeamUseCase { get createTeamUseCase(): CreateTeamUseCase {
return this._createTeamUseCase; return this._createTeamUseCase;
} }
@@ -628,6 +788,31 @@ export function getGetAllLeaguesWithCapacityQuery(): GetAllLeaguesWithCapacityQu
return DIContainer.getInstance().getAllLeaguesWithCapacityQuery; return DIContainer.getInstance().getAllLeaguesWithCapacityQuery;
} }
export function getGetAllLeaguesWithCapacityAndScoringQuery(): GetAllLeaguesWithCapacityAndScoringQuery {
return DIContainer.getInstance().getAllLeaguesWithCapacityAndScoringQuery;
}
export function getGetLeagueScoringConfigQuery(): GetLeagueScoringConfigQuery {
return DIContainer.getInstance().getLeagueScoringConfigQuery;
}
export function getGetLeagueFullConfigQuery(): GetLeagueFullConfigQuery {
return DIContainer.getInstance().getLeagueFullConfigQuery;
}
// Placeholder export for future schedule preview API wiring.
export function getPreviewLeagueScheduleQuery(): PreviewLeagueScheduleQuery {
return DIContainer.getInstance().previewLeagueScheduleQuery;
}
export function getListLeagueScoringPresetsQuery(): ListLeagueScoringPresetsQuery {
return DIContainer.getInstance().listLeagueScoringPresetsQuery;
}
export function getCreateLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase {
return DIContainer.getInstance().createLeagueWithSeasonAndScoringUseCase;
}
export function getTeamRepository(): ITeamRepository { export function getTeamRepository(): ITeamRepository {
return DIContainer.getInstance().teamRepository; return DIContainer.getInstance().teamRepository;
} }

View File

@@ -0,0 +1,60 @@
export type LeagueStructureMode = 'solo' | 'fixedTeams';
export interface LeagueStructureFormDTO {
mode: LeagueStructureMode;
maxDrivers: number;
maxTeams?: number;
driversPerTeam?: number;
multiClassEnabled?: boolean;
}
export interface LeagueChampionshipsFormDTO {
enableDriverChampionship: boolean;
enableTeamChampionship: boolean;
enableNationsChampionship: boolean;
enableTrophyChampionship: boolean;
}
export interface LeagueScoringFormDTO {
patternId?: string; // e.g. 'sprint-main-driver', 'club-ladder-solo'
// For now, keep customScoring optional and simple:
customScoringEnabled?: boolean;
}
export interface LeagueDropPolicyFormDTO {
strategy: 'none' | 'bestNResults' | 'dropWorstN';
n?: number;
}
export interface LeagueTimingsFormDTO {
practiceMinutes?: number;
qualifyingMinutes: number;
sprintRaceMinutes?: number;
mainRaceMinutes: number;
sessionCount: number;
roundsPlanned?: number;
seasonStartDate?: string; // ISO date YYYY-MM-DD
raceStartTime?: string; // "HH:MM" 24h
timezoneId?: string; // IANA ID, e.g. "Europe/Berlin"
recurrenceStrategy?: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
intervalWeeks?: number;
weekdays?: import('../../domain/value-objects/Weekday').Weekday[];
monthlyOrdinal?: 1 | 2 | 3 | 4;
monthlyWeekday?: import('../../domain/value-objects/Weekday').Weekday;
}
export interface LeagueConfigFormModel {
leagueId?: string; // present for admin, omitted for create
basics: {
name: string;
description?: string;
visibility: 'public' | 'private';
gameId: string;
};
structure: LeagueStructureFormDTO;
championships: LeagueChampionshipsFormDTO;
scoring: LeagueScoringFormDTO;
dropPolicy: LeagueDropPolicyFormDTO;
timings: LeagueTimingsFormDTO;
}

View File

@@ -0,0 +1,114 @@
import type { LeagueTimingsFormDTO } from './LeagueConfigFormDTO';
import type { Weekday } from '../../domain/value-objects/Weekday';
import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
import type { RecurrenceStrategy } from '../../domain/value-objects/RecurrenceStrategy';
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
export interface LeagueScheduleDTO {
seasonStartDate: string;
raceStartTime: string;
timezoneId: string;
recurrenceStrategy: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
intervalWeeks?: number;
weekdays?: Weekday[];
monthlyOrdinal?: 1 | 2 | 3 | 4;
monthlyWeekday?: Weekday;
plannedRounds: number;
}
export interface LeagueSchedulePreviewDTO {
rounds: Array<{ roundNumber: number; scheduledAt: string; timezoneId: string }>;
summary: string;
}
export function leagueTimingsToScheduleDTO(
timings: LeagueTimingsFormDTO,
): LeagueScheduleDTO | null {
if (
!timings.seasonStartDate ||
!timings.raceStartTime ||
!timings.timezoneId ||
!timings.recurrenceStrategy ||
!timings.roundsPlanned
) {
return null;
}
return {
seasonStartDate: timings.seasonStartDate,
raceStartTime: timings.raceStartTime,
timezoneId: timings.timezoneId,
recurrenceStrategy: timings.recurrenceStrategy,
intervalWeeks: timings.intervalWeeks,
weekdays: timings.weekdays,
monthlyOrdinal: timings.monthlyOrdinal,
monthlyWeekday: timings.monthlyWeekday,
plannedRounds: timings.roundsPlanned,
};
}
export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSchedule {
if (!dto.seasonStartDate) {
throw new Error('seasonStartDate is required');
}
if (!dto.raceStartTime) {
throw new Error('raceStartTime is required');
}
if (!dto.timezoneId) {
throw new Error('timezoneId is required');
}
if (!dto.recurrenceStrategy) {
throw new Error('recurrenceStrategy is required');
}
if (!Number.isInteger(dto.plannedRounds) || dto.plannedRounds <= 0) {
throw new Error('plannedRounds must be a positive integer');
}
const startDate = new Date(dto.seasonStartDate);
if (Number.isNaN(startDate.getTime())) {
throw new Error(`seasonStartDate must be a valid date, got "${dto.seasonStartDate}"`);
}
const timeOfDay = RaceTimeOfDay.fromString(dto.raceStartTime);
const timezone = new LeagueTimezone(dto.timezoneId);
let recurrence: RecurrenceStrategy;
if (dto.recurrenceStrategy === 'weekly') {
if (!dto.weekdays || dto.weekdays.length === 0) {
throw new Error('weekdays are required for weekly recurrence');
}
recurrence = RecurrenceStrategyFactory.weekly(new WeekdaySet(dto.weekdays));
} else if (dto.recurrenceStrategy === 'everyNWeeks') {
if (!dto.weekdays || dto.weekdays.length === 0) {
throw new Error('weekdays are required for everyNWeeks recurrence');
}
if (dto.intervalWeeks == null) {
throw new Error('intervalWeeks is required for everyNWeeks recurrence');
}
recurrence = RecurrenceStrategyFactory.everyNWeeks(
dto.intervalWeeks,
new WeekdaySet(dto.weekdays),
);
} else if (dto.recurrenceStrategy === 'monthlyNthWeekday') {
if (!dto.monthlyOrdinal || !dto.monthlyWeekday) {
throw new Error('monthlyOrdinal and monthlyWeekday are required for monthlyNthWeekday');
}
const pattern = new MonthlyRecurrencePattern(dto.monthlyOrdinal, dto.monthlyWeekday);
recurrence = RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
} else {
throw new Error(`Unknown recurrenceStrategy "${dto.recurrenceStrategy}"`);
}
return new SeasonSchedule({
startDate,
timeOfDay,
timezone,
recurrence,
plannedRounds: dto.plannedRounds,
});
}

View File

@@ -0,0 +1,20 @@
export interface LeagueScoringChampionshipDTO {
id: string;
name: string;
type: 'driver' | 'team' | 'nations' | 'trophy';
sessionTypes: string[];
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
bonusSummary: string[];
dropPolicyDescription: string;
}
export interface LeagueScoringConfigDTO {
leagueId: string;
seasonId: string;
gameId: string;
gameName: string;
scoringPresetId?: string;
scoringPresetName?: string;
dropPolicySummary: string;
championships: LeagueScoringChampionshipDTO[];
}

View File

@@ -0,0 +1,41 @@
export interface LeagueSummaryScoringDTO {
gameId: string;
gameName: string;
primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy';
scoringPresetId: string;
scoringPresetName: string;
dropPolicySummary: string;
/**
* Human-readable scoring pattern summary combining preset name and drop policy,
* e.g. "Sprint + Main • Best 6 results of 8 count towards the championship."
*/
scoringPatternSummary: string;
}
export interface LeagueSummaryDTO {
id: string;
name: string;
description?: string;
createdAt: Date;
ownerId: string;
maxDrivers?: number;
usedDriverSlots?: number;
maxTeams?: number;
usedTeamSlots?: number;
/**
* Human-readable structure summary derived from capacity and (future) team settings,
* e.g. "Solo • 24 drivers" or "Teams • 12 × 2 drivers".
*/
structureSummary?: string;
/**
* Human-readable scoring pattern summary for list views,
* e.g. "Sprint + Main • Best 6 results of 8 count towards the championship."
*/
scoringPatternSummary?: string;
/**
* Human-readable timing summary for list views,
* e.g. "30 min Quali • 40 min Race".
*/
timingSummary?: string;
scoring?: LeagueSummaryScoringDTO;
}

View File

@@ -17,15 +17,21 @@ export * from './use-cases/GetDriverTeamQuery';
export * from './use-cases/GetLeagueStandingsQuery'; export * from './use-cases/GetLeagueStandingsQuery';
export * from './use-cases/GetLeagueDriverSeasonStatsQuery'; export * from './use-cases/GetLeagueDriverSeasonStatsQuery';
export * from './use-cases/GetAllLeaguesWithCapacityQuery'; export * from './use-cases/GetAllLeaguesWithCapacityQuery';
export * from './use-cases/GetAllLeaguesWithCapacityAndScoringQuery';
export * from './use-cases/ListLeagueScoringPresetsQuery';
export * from './use-cases/GetLeagueScoringConfigQuery';
export * from './use-cases/RecalculateChampionshipStandingsUseCase'; export * from './use-cases/RecalculateChampionshipStandingsUseCase';
export * from './use-cases/CreateLeagueWithSeasonAndScoringUseCase';
export * from './use-cases/GetLeagueFullConfigQuery';
export * from './use-cases/PreviewLeagueScheduleQuery';
// Re-export domain types for legacy callers (type-only) // Re-export domain types for legacy callers (type-only)
export type { export type {
LeagueMembership, LeagueMembership,
MembershipRole, MembershipRole,
MembershipStatus, MembershipStatus,
JoinRequest, JoinRequest,
} from '../domain/entities/LeagueMembership'; } from '../domain/entities/LeagueMembership';
export type { RaceRegistration } from '../domain/entities/RaceRegistration'; export type { RaceRegistration } from '../domain/entities/RaceRegistration';
@@ -43,7 +49,20 @@ export type { RaceDTO } from './dto/RaceDTO';
export type { ResultDTO } from './dto/ResultDTO'; export type { ResultDTO } from './dto/ResultDTO';
export type { StandingDTO } from './dto/StandingDTO'; export type { StandingDTO } from './dto/StandingDTO';
export type { LeagueDriverSeasonStatsDTO } from './dto/LeagueDriverSeasonStatsDTO'; export type { LeagueDriverSeasonStatsDTO } from './dto/LeagueDriverSeasonStatsDTO';
export type {
LeagueScheduleDTO,
LeagueSchedulePreviewDTO,
} from './dto/LeagueScheduleDTO';
export type { export type {
ChampionshipStandingsDTO, ChampionshipStandingsDTO,
ChampionshipStandingsRowDTO, ChampionshipStandingsRowDTO,
} from './dto/ChampionshipStandingsDTO'; } from './dto/ChampionshipStandingsDTO';
export type {
LeagueConfigFormModel,
LeagueStructureFormDTO,
LeagueChampionshipsFormDTO,
LeagueScoringFormDTO,
LeagueDropPolicyFormDTO,
LeagueStructureMode,
LeagueTimingsFormDTO,
} from './dto/LeagueConfigFormDTO';

View File

@@ -0,0 +1,26 @@
export type LeagueScoringPresetPrimaryChampionshipType =
| 'driver'
| 'team'
| 'nations'
| 'trophy';
export interface LeagueScoringPresetDTO {
id: string;
name: string;
description: string;
primaryChampionshipType: LeagueScoringPresetPrimaryChampionshipType;
sessionSummary: string;
bonusSummary: string;
dropPolicySummary: string;
}
/**
* Provider abstraction for league scoring presets used by application-layer queries.
*
* In-memory implementation is backed by the preset registry in
* InMemoryScoringRepositories.
*/
export interface LeagueScoringPresetProvider {
listPresets(): LeagueScoringPresetDTO[];
getPresetById(id: string): LeagueScoringPresetDTO | undefined;
}

View File

@@ -0,0 +1,141 @@
import { v4 as uuidv4 } from 'uuid';
import { League } from '../../domain/entities/League';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
import type {
LeagueScoringPresetProvider,
LeagueScoringPresetDTO,
} from '../ports/LeagueScoringPresetProvider';
export interface CreateLeagueWithSeasonAndScoringCommand {
name: string;
description?: string;
visibility: 'public' | 'private';
ownerId: string;
gameId: string;
maxDrivers?: number;
maxTeams?: number;
enableDriverChampionship: boolean;
enableTeamChampionship: boolean;
enableNationsChampionship: boolean;
enableTrophyChampionship: boolean;
scoringPresetId?: string;
}
export interface CreateLeagueWithSeasonAndScoringResultDTO {
leagueId: string;
seasonId: string;
scoringPresetId?: string;
scoringPresetName?: string;
}
export class CreateLeagueWithSeasonAndScoringUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly presetProvider: LeagueScoringPresetProvider,
) {}
async execute(
command: CreateLeagueWithSeasonAndScoringCommand,
): Promise<CreateLeagueWithSeasonAndScoringResultDTO> {
this.validate(command);
const leagueId = uuidv4();
const league = League.create({
id: leagueId,
name: command.name,
description: command.description ?? '',
ownerId: command.ownerId,
settings: {
pointsSystem: (command.scoringPresetId as any) ?? 'custom',
maxDrivers: command.maxDrivers,
},
});
await this.leagueRepository.create(league);
const seasonId = uuidv4();
const season = {
id: seasonId,
leagueId: league.id,
gameId: command.gameId,
name: `${command.name} Season 1`,
year: new Date().getFullYear(),
order: 1,
status: 'active' as const,
startDate: new Date(),
endDate: new Date(),
};
// Season is a domain entity; use the repository's create, but shape matches Season.create expectations.
// To keep this use case independent, we rely on repository to persist the plain object.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await this.seasonRepository.create(season as any);
const presetId = command.scoringPresetId ?? 'club-default';
const preset: LeagueScoringPresetDTO | undefined =
this.presetProvider.getPresetById(presetId);
if (!preset) {
throw new Error(`Unknown scoring preset: ${presetId}`);
}
const scoringConfig: LeagueScoringConfig = {
id: uuidv4(),
seasonId,
scoringPresetId: preset.id,
championships: [],
};
// For the initial alpha slice, we keep using the preset's config shape from the in-memory registry.
// The preset registry is responsible for building the full LeagueScoringConfig; we only attach the preset id here.
const fullConfigFactory = (await import(
'../../infrastructure/repositories/InMemoryScoringRepositories'
)) as typeof import('../../infrastructure/repositories/InMemoryScoringRepositories');
const presetFromInfra = fullConfigFactory.getLeagueScoringPresetById(
preset.id,
);
if (!presetFromInfra) {
throw new Error(`Preset registry missing preset: ${preset.id}`);
}
const infraConfig = presetFromInfra.createConfig({ seasonId });
const finalConfig: LeagueScoringConfig = {
...infraConfig,
scoringPresetId: preset.id,
};
await this.leagueScoringConfigRepository.save(finalConfig);
return {
leagueId: league.id,
seasonId,
scoringPresetId: preset.id,
scoringPresetName: preset.name,
};
}
private validate(command: CreateLeagueWithSeasonAndScoringCommand): void {
if (!command.name || command.name.trim().length === 0) {
throw new Error('League name is required');
}
if (!command.ownerId || command.ownerId.trim().length === 0) {
throw new Error('League ownerId is required');
}
if (!command.gameId || command.gameId.trim().length === 0) {
throw new Error('gameId is required');
}
if (!command.visibility) {
throw new Error('visibility is required');
}
if (command.maxDrivers !== undefined && command.maxDrivers <= 0) {
throw new Error('maxDrivers must be greater than 0 when provided');
}
}
}

View File

@@ -0,0 +1,163 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type {
LeagueScoringPresetProvider,
LeagueScoringPresetDTO,
} from '../ports/LeagueScoringPresetProvider';
import type {
LeagueSummaryDTO,
LeagueSummaryScoringDTO,
} from '../dto/LeagueSummaryDTO';
/**
* Combined capacity + scoring summary query for leagues.
*
* Extends the behavior of GetAllLeaguesWithCapacityQuery by including
* scoring preset and game summaries when an active season and
* LeagueScoringConfig are available.
*/
export class GetAllLeaguesWithCapacityAndScoringQuery {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
private readonly presetProvider: LeagueScoringPresetProvider,
) {}
async execute(): Promise<LeagueSummaryDTO[]> {
const leagues = await this.leagueRepository.findAll();
const results: LeagueSummaryDTO[] = [];
for (const league of leagues) {
const members = await this.leagueMembershipRepository.getLeagueMembers(
league.id,
);
const usedDriverSlots = members.filter(
(m) =>
m.status === 'active' &&
(m.role === 'owner' ||
m.role === 'admin' ||
m.role === 'steward' ||
m.role === 'member'),
).length;
const configuredMaxDrivers = league.settings.maxDrivers ?? usedDriverSlots;
const safeMaxDrivers = Math.max(configuredMaxDrivers, usedDriverSlots);
const scoringSummary = await this.buildScoringSummary(league.id);
const structureSummary = `Solo • ${safeMaxDrivers} drivers`;
const qualifyingMinutes = 30;
const mainRaceMinutes =
typeof league.settings.sessionDuration === 'number'
? league.settings.sessionDuration
: 40;
const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`;
const dto: LeagueSummaryDTO = {
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
createdAt: league.createdAt,
maxDrivers: safeMaxDrivers,
usedDriverSlots,
maxTeams: undefined,
usedTeamSlots: undefined,
structureSummary,
scoringPatternSummary: scoringSummary?.scoringPatternSummary,
timingSummary,
scoring: scoringSummary,
};
results.push(dto);
}
return results;
}
private async buildScoringSummary(
leagueId: string,
): Promise<LeagueSummaryScoringDTO | undefined> {
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
if (!seasons || seasons.length === 0) {
return undefined;
}
const activeSeason =
seasons.find((s) => s.status === 'active') ?? seasons[0];
const scoringConfig =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
if (!scoringConfig) {
return undefined;
}
const game = await this.gameRepository.findById(activeSeason.gameId);
if (!game) {
return undefined;
}
const presetId = scoringConfig.scoringPresetId;
let preset: LeagueScoringPresetDTO | undefined;
if (presetId) {
preset = this.presetProvider.getPresetById(presetId);
}
const dropPolicySummary =
preset?.dropPolicySummary ?? this.deriveDropPolicySummary(scoringConfig);
const primaryChampionshipType =
preset?.primaryChampionshipType ??
(scoringConfig.championships[0]?.type ?? 'driver');
const scoringPresetName = preset?.name ?? 'Custom';
const scoringPatternSummary = `${scoringPresetName}${dropPolicySummary}`;
return {
gameId: game.id,
gameName: game.name,
primaryChampionshipType,
scoringPresetId: presetId ?? 'custom',
scoringPresetName,
dropPolicySummary,
scoringPatternSummary,
};
}
private deriveDropPolicySummary(config: {
championships: Array<{
dropScorePolicy: { strategy: string; count?: number; dropCount?: number };
}>;
}): string {
const championship = config.championships[0];
if (!championship) {
return 'All results count';
}
const policy = championship.dropScorePolicy;
if (!policy || policy.strategy === 'none') {
return 'All results count';
}
if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') {
return `Best ${policy.count} results count`;
}
if (
policy.strategy === 'dropWorstN' &&
typeof policy.dropCount === 'number'
) {
return `Worst ${policy.dropCount} results are dropped`;
}
return 'Custom drop score rules';
}
}

View File

@@ -0,0 +1,144 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig';
import type { DropScorePolicy } from '../../domain/value-objects/DropScorePolicy';
import type {
LeagueConfigFormModel,
LeagueDropPolicyFormDTO,
} from '../dto/LeagueConfigFormDTO';
/**
* Query returning a unified LeagueConfigFormModel for a given league.
*
* First iteration focuses on:
* - Basics derived from League
* - Simple solo structure derived from League.settings.maxDrivers
* - Championships flags with driver enabled and others disabled
* - Scoring pattern id taken from LeagueScoringConfig.scoringPresetId
* - Drop policy inferred from the primary championship configuration
*/
export class GetLeagueFullConfigQuery {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
) {}
async execute(params: { leagueId: string }): Promise<LeagueConfigFormModel | null> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
return null;
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
const activeSeason =
seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: null;
const scoringConfig = activeSeason
? await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id)
: null;
const game =
activeSeason && activeSeason.gameId
? await this.gameRepository.findById(activeSeason.gameId)
: null;
const patternId = scoringConfig?.scoringPresetId;
const primaryChampionship: ChampionshipConfig | undefined =
scoringConfig && scoringConfig.championships && scoringConfig.championships.length > 0
? scoringConfig.championships[0]
: undefined;
const dropPolicy: DropScorePolicy | undefined =
primaryChampionship?.dropScorePolicy ?? undefined;
const dropPolicyForm: LeagueDropPolicyFormDTO = this.mapDropPolicy(dropPolicy);
const defaultQualifyingMinutes = 30;
const defaultMainRaceMinutes = 40;
const mainRaceMinutes =
typeof league.settings.sessionDuration === 'number'
? league.settings.sessionDuration
: defaultMainRaceMinutes;
const qualifyingMinutes = defaultQualifyingMinutes;
const roundsPlanned = 8;
let sessionCount = 2;
if (
primaryChampionship &&
Array.isArray((primaryChampionship as any).sessionTypes) &&
(primaryChampionship as any).sessionTypes.length > 0
) {
sessionCount = (primaryChampionship as any).sessionTypes.length;
}
const practiceMinutes = 20;
const sprintRaceMinutes = patternId === 'sprint-main-driver' ? 20 : undefined;
const form: LeagueConfigFormModel = {
leagueId: league.id,
basics: {
name: league.name,
description: league.description,
visibility: 'public', // current domain model does not track visibility; default to public for now
gameId: game?.id ?? 'iracing',
},
structure: {
// First slice: treat everything as solo structure based on maxDrivers
mode: 'solo',
maxDrivers: league.settings.maxDrivers ?? 32,
maxTeams: undefined,
driversPerTeam: undefined,
multiClassEnabled: false,
},
championships: {
enableDriverChampionship: true,
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
},
scoring: {
patternId: patternId ?? undefined,
customScoringEnabled: !patternId,
},
dropPolicy: dropPolicyForm,
timings: {
practiceMinutes,
qualifyingMinutes,
sprintRaceMinutes,
mainRaceMinutes,
sessionCount,
roundsPlanned,
},
};
return form;
}
private mapDropPolicy(policy: DropScorePolicy | undefined): LeagueDropPolicyFormDTO {
if (!policy || policy.strategy === 'none') {
return { strategy: 'none' };
}
if (policy.strategy === 'bestNResults') {
const n = typeof policy.count === 'number' ? policy.count : undefined;
return n !== undefined ? { strategy: 'bestNResults', n } : { strategy: 'none' };
}
if (policy.strategy === 'dropWorstN') {
const n = typeof policy.dropCount === 'number' ? policy.dropCount : undefined;
return n !== undefined ? { strategy: 'dropWorstN', n } : { strategy: 'none' };
}
return { strategy: 'none' };
}
}

View File

@@ -0,0 +1,190 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { LeagueScoringConfigDTO } from '../dto/LeagueScoringConfigDTO';
import type { LeagueScoringChampionshipDTO } from '../dto/LeagueScoringConfigDTO';
import type {
LeagueScoringPresetProvider,
LeagueScoringPresetDTO,
} from '../ports/LeagueScoringPresetProvider';
import type { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig';
import type { PointsTable } from '../../domain/value-objects/PointsTable';
import type { BonusRule } from '../../domain/value-objects/BonusRule';
/**
* Query returning a league's scoring configuration for its active season.
*
* Designed for the league detail "Scoring" tab.
*/
export class GetLeagueScoringConfigQuery {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
private readonly presetProvider: LeagueScoringPresetProvider,
) {}
async execute(params: { leagueId: string }): Promise<LeagueScoringConfigDTO | null> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
return null;
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
if (!seasons || seasons.length === 0) {
return null;
}
const activeSeason =
seasons.find((s) => s.status === 'active') ?? seasons[0];
const scoringConfig =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
if (!scoringConfig) {
return null;
}
const game = await this.gameRepository.findById(activeSeason.gameId);
if (!game) {
return null;
}
const presetId = scoringConfig.scoringPresetId;
const preset: LeagueScoringPresetDTO | undefined =
presetId ? this.presetProvider.getPresetById(presetId) : undefined;
const championships: LeagueScoringChampionshipDTO[] =
scoringConfig.championships.map((champ) =>
this.mapChampionship(champ),
);
const dropPolicySummary =
preset?.dropPolicySummary ??
this.deriveDropPolicyDescriptionFromChampionships(
scoringConfig.championships,
);
return {
leagueId: league.id,
seasonId: activeSeason.id,
gameId: game.id,
gameName: game.name,
scoringPresetId: presetId,
scoringPresetName: preset?.name,
dropPolicySummary,
championships,
};
}
private mapChampionship(championship: ChampionshipConfig): LeagueScoringChampionshipDTO {
const sessionTypes = championship.sessionTypes.map((s) => s.toString());
const pointsPreview = this.buildPointsPreview(championship.pointsTableBySessionType);
const bonusSummary = this.buildBonusSummary(
championship.bonusRulesBySessionType ?? {},
);
const dropPolicyDescription = this.deriveDropPolicyDescription(
championship.dropScorePolicy,
);
return {
id: championship.id,
name: championship.name,
type: championship.type,
sessionTypes,
pointsPreview,
bonusSummary,
dropPolicyDescription,
};
}
private buildPointsPreview(
tables: Record<string, PointsTable>,
): Array<{ sessionType: string; position: number; points: number }> {
const preview: Array<{
sessionType: string;
position: number;
points: number;
}> = [];
const maxPositions = 10;
for (const [sessionType, table] of Object.entries(tables)) {
for (let pos = 1; pos <= maxPositions; pos++) {
const points = table.getPoints(pos);
if (points && points !== 0) {
preview.push({
sessionType,
position: pos,
points,
});
}
}
}
return preview;
}
private buildBonusSummary(
bonusRulesBySessionType: Record<string, BonusRule[]>,
): string[] {
const summaries: string[] = [];
for (const [sessionType, rules] of Object.entries(bonusRulesBySessionType)) {
for (const rule of rules) {
if (rule.type === 'fastestLap') {
const base = `Fastest lap in ${sessionType}`;
if (rule.requiresFinishInTopN) {
summaries.push(
`${base} +${rule.points} points if finishing P${rule.requiresFinishInTopN} or better`,
);
} else {
summaries.push(`${base} +${rule.points} points`);
}
} else {
summaries.push(
`${rule.type} bonus in ${sessionType} worth ${rule.points} points`,
);
}
}
}
return summaries;
}
private deriveDropPolicyDescriptionFromChampionships(
championships: ChampionshipConfig[],
): string {
const first = championships[0];
if (!first) {
return 'All results count';
}
return this.deriveDropPolicyDescription(first.dropScorePolicy);
}
private deriveDropPolicyDescription(policy: {
strategy: string;
count?: number;
dropCount?: number;
}): string {
if (!policy || policy.strategy === 'none') {
return 'All results count';
}
if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') {
return `Best ${policy.count} results count towards the championship`;
}
if (
policy.strategy === 'dropWorstN' &&
typeof policy.dropCount === 'number'
) {
return `Worst ${policy.dropCount} results are dropped from the championship total`;
}
return 'Custom drop score rules apply';
}
}

View File

@@ -0,0 +1,18 @@
import type {
LeagueScoringPresetDTO,
LeagueScoringPresetProvider,
} from '../ports/LeagueScoringPresetProvider';
/**
* Read-only query exposing league scoring presets for UI consumption.
*
* Backed by the in-memory preset registry via a LeagueScoringPresetProvider
* implementation in the infrastructure layer.
*/
export class ListLeagueScoringPresetsQuery {
constructor(private readonly presetProvider: LeagueScoringPresetProvider) {}
async execute(): Promise<LeagueScoringPresetDTO[]> {
return this.presetProvider.listPresets();
}
}

View File

@@ -0,0 +1,90 @@
import { SeasonScheduleGenerator } from '../../domain/services/SeasonScheduleGenerator';
import type { LeagueSchedulePreviewDTO, LeagueScheduleDTO } from '../dto/LeagueScheduleDTO';
import { scheduleDTOToSeasonSchedule } from '../dto/LeagueScheduleDTO';
interface PreviewLeagueScheduleQueryParams {
schedule: LeagueScheduleDTO;
maxRounds?: number;
}
export class PreviewLeagueScheduleQuery {
constructor(
private readonly scheduleGenerator: typeof SeasonScheduleGenerator = SeasonScheduleGenerator,
) {}
execute(params: PreviewLeagueScheduleQueryParams): LeagueSchedulePreviewDTO {
const seasonSchedule = scheduleDTOToSeasonSchedule(params.schedule);
const maxRounds =
params.maxRounds && params.maxRounds > 0
? Math.min(params.maxRounds, seasonSchedule.plannedRounds)
: seasonSchedule.plannedRounds;
const slots = this.scheduleGenerator.generateSlotsUpTo(seasonSchedule, maxRounds);
const rounds = slots.map((slot) => ({
roundNumber: slot.roundNumber,
scheduledAt: slot.scheduledAt.toISOString(),
timezoneId: slot.timezone.getId(),
}));
const summary = this.buildSummary(params.schedule, rounds);
return {
rounds,
summary,
};
}
private buildSummary(
schedule: LeagueScheduleDTO,
rounds: Array<{ roundNumber: number; scheduledAt: string; timezoneId: string }>,
): string {
if (rounds.length === 0) {
return 'No rounds scheduled.';
}
const first = new Date(rounds[0].scheduledAt);
const last = new Date(rounds[rounds.length - 1].scheduledAt);
const firstDate = first.toISOString().slice(0, 10);
const lastDate = last.toISOString().slice(0, 10);
const timePart = schedule.raceStartTime;
const tz = schedule.timezoneId;
let recurrenceDescription: string;
if (schedule.recurrenceStrategy === 'weekly') {
const days = (schedule.weekdays ?? []).join(', ');
recurrenceDescription = `Every ${days}`;
} else if (schedule.recurrenceStrategy === 'everyNWeeks') {
const interval = schedule.intervalWeeks ?? 1;
const days = (schedule.weekdays ?? []).join(', ');
recurrenceDescription = `Every ${interval} week(s) on ${days}`;
} else if (schedule.recurrenceStrategy === 'monthlyNthWeekday') {
const ordinalLabel = this.ordinalToLabel(schedule.monthlyOrdinal ?? 1);
const weekday = schedule.monthlyWeekday ?? 'Mon';
recurrenceDescription = `Every ${ordinalLabel} ${weekday}`;
} else {
recurrenceDescription = 'Custom recurrence';
}
return `${recurrenceDescription} at ${timePart} ${tz}, starting ${firstDate}${rounds.length} rounds from ${firstDate} to ${lastDate}.`;
}
private ordinalToLabel(ordinal: 1 | 2 | 3 | 4): string {
switch (ordinal) {
case 1:
return '1st';
case 2:
return '2nd';
case 3:
return '3rd';
case 4:
return '4th';
default:
return `${ordinal}th`;
}
}
}

View File

@@ -3,5 +3,11 @@ import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
export interface LeagueScoringConfig { export interface LeagueScoringConfig {
id: string; id: string;
seasonId: string; seasonId: string;
/**
* Optional ID of the scoring preset this configuration was derived from.
* Used by application-layer read models to surface preset metadata such as
* name and drop policy summaries.
*/
scoringPresetId?: string;
championships: ChampionshipConfig[]; championships: ChampionshipConfig[];
} }

View File

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

View File

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

View File

@@ -0,0 +1,175 @@
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
import type { Weekday } from '../value-objects/Weekday';
import { weekdayToIndex } from '../value-objects/Weekday';
function cloneDate(date: Date): Date {
return new Date(date.getTime());
}
function addDays(date: Date, days: number): Date {
const d = cloneDate(date);
d.setDate(d.getDate() + days);
return d;
}
function addWeeks(date: Date, weeks: number): Date {
return addDays(date, weeks * 7);
}
function addMonths(date: Date, months: number): Date {
const d = cloneDate(date);
const targetMonth = d.getMonth() + months;
d.setMonth(targetMonth);
return d;
}
function applyTimeOfDay(baseDate: Date, timeOfDay: RaceTimeOfDay): Date {
const d = new Date(
baseDate.getFullYear(),
baseDate.getMonth(),
baseDate.getDate(),
timeOfDay.hour,
timeOfDay.minute,
0,
0,
);
return d;
}
// Treat Monday as 1 ... Sunday as 7
function getCalendarWeekdayIndex(date: Date): number {
const jsDay = date.getDay(); // 0=Sun ... 6=Sat
if (jsDay === 0) {
return 7;
}
return jsDay;
}
function weekdayToCalendarOffset(anchor: Date, target: Weekday): number {
const anchorIndex = getCalendarWeekdayIndex(anchor);
const targetIndex = weekdayToIndex(target);
return targetIndex - anchorIndex;
}
function generateWeeklyOrEveryNWeeksSlots(
schedule: SeasonSchedule,
maxRounds: number,
): ScheduledRaceSlot[] {
const result: ScheduledRaceSlot[] = [];
const recurrence = schedule.recurrence;
const weekdays =
recurrence.kind === 'weekly' || recurrence.kind === 'everyNWeeks'
? recurrence.weekdays.getAll()
: [];
if (weekdays.length === 0) {
throw new Error('RecurrenceStrategy has no weekdays');
}
const intervalWeeks = recurrence.kind === 'everyNWeeks' ? recurrence.intervalWeeks : 1;
let anchorWeekStart = cloneDate(schedule.startDate);
let roundNumber = 1;
while (result.length < maxRounds) {
for (const weekday of weekdays) {
const offset = weekdayToCalendarOffset(anchorWeekStart, weekday);
const candidateDate = addDays(anchorWeekStart, offset);
if (candidateDate < schedule.startDate) {
continue;
}
const scheduledAt = applyTimeOfDay(candidateDate, schedule.timeOfDay);
result.push(
new ScheduledRaceSlot({
roundNumber,
scheduledAt,
timezone: schedule.timezone,
}),
);
roundNumber += 1;
if (result.length >= maxRounds) {
break;
}
}
anchorWeekStart = addWeeks(anchorWeekStart, intervalWeeks);
}
return result;
}
function findNthWeekdayOfMonth(base: Date, ordinal: 1 | 2 | 3 | 4, weekday: Weekday): Date {
const firstOfMonth = new Date(base.getFullYear(), base.getMonth(), 1);
const firstIndex = getCalendarWeekdayIndex(firstOfMonth);
const targetIndex = weekdayToIndex(weekday);
let offset = targetIndex - firstIndex;
if (offset < 0) {
offset += 7;
}
const dayOfMonth = 1 + offset + (ordinal - 1) * 7;
return new Date(base.getFullYear(), base.getMonth(), dayOfMonth);
}
function generateMonthlySlots(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
const result: ScheduledRaceSlot[] = [];
const recurrence = schedule.recurrence;
if (recurrence.kind !== 'monthlyNthWeekday') {
return result;
}
const { ordinal, weekday } = recurrence.monthlyPattern;
let currentMonthDate = new Date(
schedule.startDate.getFullYear(),
schedule.startDate.getMonth(),
1,
);
let roundNumber = 1;
while (result.length < maxRounds) {
const candidateDate = findNthWeekdayOfMonth(currentMonthDate, ordinal, weekday);
if (candidateDate >= schedule.startDate) {
const scheduledAt = applyTimeOfDay(candidateDate, schedule.timeOfDay);
result.push(
new ScheduledRaceSlot({
roundNumber,
scheduledAt,
timezone: schedule.timezone,
}),
);
roundNumber += 1;
}
currentMonthDate = addMonths(currentMonthDate, 1);
}
return result;
}
export class SeasonScheduleGenerator {
static generateSlots(schedule: SeasonSchedule): ScheduledRaceSlot[] {
return this.generateSlotsUpTo(schedule, schedule.plannedRounds);
}
static generateSlotsUpTo(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
if (!Number.isInteger(maxRounds) || maxRounds <= 0) {
throw new Error('maxRounds must be a positive integer');
}
const recurrence: RecurrenceStrategy = schedule.recurrence;
if (recurrence.kind === 'monthlyNthWeekday') {
return generateMonthlySlots(schedule, maxRounds);
}
return generateWeeklyOrEveryNWeeksSlots(schedule, maxRounds);
}
}

View File

@@ -0,0 +1,14 @@
export class LeagueTimezone {
private readonly id: string;
constructor(id: string) {
if (!id || id.trim().length === 0) {
throw new Error('LeagueTimezone id must be a non-empty string');
}
this.id = id;
}
getId(): string {
return this.id;
}
}

View File

@@ -0,0 +1,11 @@
import type { Weekday } from './Weekday';
export class MonthlyRecurrencePattern {
readonly ordinal: 1 | 2 | 3 | 4;
readonly weekday: Weekday;
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday) {
this.ordinal = ordinal;
this.weekday = weekday;
}
}

View File

@@ -0,0 +1,34 @@
export class RaceTimeOfDay {
readonly hour: number;
readonly minute: number;
constructor(hour: number, minute: number) {
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
throw new Error(`RaceTimeOfDay hour must be between 0 and 23, got ${hour}`);
}
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
throw new Error(`RaceTimeOfDay minute must be between 0 and 59, got ${minute}`);
}
this.hour = hour;
this.minute = minute;
}
static fromString(value: string): RaceTimeOfDay {
const match = /^(\d{2}):(\d{2})$/.exec(value);
if (!match) {
throw new Error(`RaceTimeOfDay string must be in HH:MM 24h format, got "${value}"`);
}
const hour = Number(match[1]);
const minute = Number(match[2]);
return new RaceTimeOfDay(hour, minute);
}
toString(): string {
const hh = this.hour.toString().padStart(2, '0');
const mm = this.minute.toString().padStart(2, '0');
return `${hh}:${mm}`;
}
}

View File

@@ -0,0 +1,53 @@
import { WeekdaySet } from './WeekdaySet';
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
export type RecurrenceStrategyKind = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
export type WeeklyRecurrence = {
kind: 'weekly';
weekdays: WeekdaySet;
};
export type EveryNWeeksRecurrence = {
kind: 'everyNWeeks';
intervalWeeks: number;
weekdays: WeekdaySet;
};
export type MonthlyNthWeekdayRecurrence = {
kind: 'monthlyNthWeekday';
monthlyPattern: MonthlyRecurrencePattern;
};
export type RecurrenceStrategy =
| WeeklyRecurrence
| EveryNWeeksRecurrence
| MonthlyNthWeekdayRecurrence;
export class RecurrenceStrategyFactory {
static weekly(weekdays: WeekdaySet): RecurrenceStrategy {
return {
kind: 'weekly',
weekdays,
};
}
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
if (!Number.isInteger(intervalWeeks) || intervalWeeks < 1 || intervalWeeks > 12) {
throw new Error('everyNWeeks intervalWeeks must be an integer between 1 and 12');
}
return {
kind: 'everyNWeeks',
intervalWeeks,
weekdays,
};
}
static monthlyNthWeekday(monthlyPattern: MonthlyRecurrencePattern): RecurrenceStrategy {
return {
kind: 'monthlyNthWeekday',
monthlyPattern,
};
}
}

View File

@@ -0,0 +1,20 @@
import { LeagueTimezone } from './LeagueTimezone';
export class ScheduledRaceSlot {
readonly roundNumber: number;
readonly scheduledAt: Date;
readonly timezone: LeagueTimezone;
constructor(params: { roundNumber: number; scheduledAt: Date; timezone: LeagueTimezone }) {
if (!Number.isInteger(params.roundNumber) || params.roundNumber <= 0) {
throw new Error('ScheduledRaceSlot.roundNumber must be a positive integer');
}
if (!(params.scheduledAt instanceof Date) || Number.isNaN(params.scheduledAt.getTime())) {
throw new Error('ScheduledRaceSlot.scheduledAt must be a valid Date');
}
this.roundNumber = params.roundNumber;
this.scheduledAt = params.scheduledAt;
this.timezone = params.timezone;
}
}

View File

@@ -0,0 +1,36 @@
import { RaceTimeOfDay } from './RaceTimeOfDay';
import { LeagueTimezone } from './LeagueTimezone';
import type { RecurrenceStrategy } from './RecurrenceStrategy';
export class SeasonSchedule {
readonly startDate: Date;
readonly timeOfDay: RaceTimeOfDay;
readonly timezone: LeagueTimezone;
readonly recurrence: RecurrenceStrategy;
readonly plannedRounds: number;
constructor(params: {
startDate: Date;
timeOfDay: RaceTimeOfDay;
timezone: LeagueTimezone;
recurrence: RecurrenceStrategy;
plannedRounds: number;
}) {
if (!(params.startDate instanceof Date) || Number.isNaN(params.startDate.getTime())) {
throw new Error('SeasonSchedule.startDate must be a valid Date');
}
if (!Number.isInteger(params.plannedRounds) || params.plannedRounds <= 0) {
throw new Error('SeasonSchedule.plannedRounds must be a positive integer');
}
this.startDate = new Date(
params.startDate.getFullYear(),
params.startDate.getMonth(),
params.startDate.getDate(),
);
this.timeOfDay = params.timeOfDay;
this.timezone = params.timezone;
this.recurrence = params.recurrence;
this.plannedRounds = params.plannedRounds;
}
}

View File

@@ -0,0 +1,25 @@
export type Weekday = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun';
export const ALL_WEEKDAYS: Weekday[] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
export function weekdayToIndex(day: Weekday): number {
switch (day) {
case 'Mon':
return 1;
case 'Tue':
return 2;
case 'Wed':
return 3;
case 'Thu':
return 4;
case 'Fri':
return 5;
case 'Sat':
return 6;
case 'Sun':
return 7;
default:
// This should be unreachable because Weekday is a closed union.
throw new Error(`Unknown weekday: ${day}`);
}
}

View File

@@ -0,0 +1,23 @@
import type { Weekday } from './Weekday';
import { weekdayToIndex } from './Weekday';
export class WeekdaySet {
private readonly days: Weekday[];
constructor(days: Weekday[]) {
if (!Array.isArray(days) || days.length === 0) {
throw new Error('WeekdaySet requires at least one weekday');
}
const unique = Array.from(new Set(days));
this.days = unique.sort((a, b) => weekdayToIndex(a) - weekdayToIndex(b));
}
getAll(): Weekday[] {
return [...this.days];
}
includes(day: Weekday): boolean {
return this.days.includes(day);
}
}

View File

@@ -0,0 +1,45 @@
import {
getLeagueScoringPresetById,
listLeagueScoringPresets,
} from './InMemoryScoringRepositories';
import type {
LeagueScoringPresetDTO,
LeagueScoringPresetProvider,
} from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
/**
* Infrastructure adapter exposing the in-memory scoring preset registry
* through the LeagueScoringPresetProvider application port.
*/
export class InMemoryLeagueScoringPresetProvider
implements LeagueScoringPresetProvider
{
listPresets(): LeagueScoringPresetDTO[] {
return listLeagueScoringPresets().map((preset) => ({
id: preset.id,
name: preset.name,
description: preset.description,
primaryChampionshipType: preset.primaryChampionshipType,
sessionSummary: preset.sessionSummary,
bonusSummary: preset.bonusSummary,
dropPolicySummary: preset.dropPolicySummary,
}));
}
getPresetById(id: string): LeagueScoringPresetDTO | undefined {
const preset = getLeagueScoringPresetById(id);
if (!preset) {
return undefined;
}
return {
id: preset.id,
name: preset.name,
description: preset.description,
primaryChampionshipType: preset.primaryChampionshipType,
sessionSummary: preset.sessionSummary,
bonusSummary: preset.bonusSummary,
dropPolicySummary: preset.dropPolicySummary,
};
}
}

View File

@@ -14,6 +14,238 @@ import { ChampionshipStanding } from '@gridpilot/racing/domain/entities/Champion
import type { ChampionshipType } from '@gridpilot/racing/domain/value-objects/ChampionshipType'; import type { ChampionshipType } from '@gridpilot/racing/domain/value-objects/ChampionshipType';
import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef'; import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef';
export type LeagueScoringPresetPrimaryChampionshipType =
| 'driver'
| 'team'
| 'nations'
| 'trophy';
export interface LeagueScoringPreset {
id: string;
name: string;
description: string;
primaryChampionshipType: LeagueScoringPresetPrimaryChampionshipType;
dropPolicySummary: string;
sessionSummary: string;
bonusSummary: string;
createConfig: (options: { seasonId: string }) => LeagueScoringConfig;
}
const mainPointsSprintMain = new PointsTable({
1: 25,
2: 18,
3: 15,
4: 12,
5: 10,
6: 8,
7: 6,
8: 4,
9: 2,
10: 1,
});
const sprintPointsSprintMain = new PointsTable({
1: 8,
2: 7,
3: 6,
4: 5,
5: 4,
6: 3,
7: 2,
8: 1,
});
const clubMainPoints = new PointsTable({
1: 20,
2: 15,
3: 12,
4: 10,
5: 8,
6: 6,
7: 4,
8: 2,
9: 1,
});
const enduranceMainPoints = new PointsTable({
1: 50,
2: 36,
3: 30,
4: 24,
5: 20,
6: 16,
7: 12,
8: 8,
9: 4,
10: 2,
});
const leagueScoringPresets: LeagueScoringPreset[] = [
{
id: 'sprint-main-driver',
name: 'Sprint + Main',
description:
'Short sprint race plus main race; sprint gives fewer points.',
primaryChampionshipType: 'driver',
dropPolicySummary: 'Best 6 results of 8 count towards the championship.',
sessionSummary: 'Sprint + Main',
bonusSummary: 'Fastest lap +1 point in main race if finishing P10 or better.',
createConfig: ({ seasonId }) => {
const fastestLapBonus: BonusRule = {
id: 'fastest-lap-main',
type: 'fastestLap',
points: 1,
requiresFinishInTopN: 10,
};
const sessionTypes: SessionType[] = ['sprint', 'main'];
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
sprint: sprintPointsSprintMain,
main: mainPointsSprintMain,
practice: new PointsTable({}),
qualifying: new PointsTable({}),
q1: new PointsTable({}),
q2: new PointsTable({}),
q3: new PointsTable({}),
timeTrial: new PointsTable({}),
};
const bonusRulesBySessionType: Record<SessionType, BonusRule[]> = {
sprint: [],
main: [fastestLapBonus],
practice: [],
qualifying: [],
q1: [],
q2: [],
q3: [],
timeTrial: [],
};
const dropScorePolicy: DropScorePolicy = {
strategy: 'bestNResults',
count: 6,
};
const championship: ChampionshipConfig = {
id: 'driver-champ-sprint-main',
name: 'Driver Championship',
type: 'driver' as ChampionshipType,
sessionTypes,
pointsTableBySessionType,
bonusRulesBySessionType,
dropScorePolicy,
};
return {
id: `lsc-${seasonId}-sprint-main-driver`,
seasonId,
scoringPresetId: 'sprint-main-driver',
championships: [championship],
};
},
},
{
id: 'club-default',
name: 'Club ladder',
description:
'Simple club ladder with a single main race and no bonuses or drop scores.',
primaryChampionshipType: 'driver',
dropPolicySummary: 'All race results count, no drop scores.',
sessionSummary: 'Main race only',
bonusSummary: 'No bonus points.',
createConfig: ({ seasonId }) => {
const sessionTypes: SessionType[] = ['main'];
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
sprint: new PointsTable({}),
main: clubMainPoints,
practice: new PointsTable({}),
qualifying: new PointsTable({}),
q1: new PointsTable({}),
q2: new PointsTable({}),
q3: new PointsTable({}),
timeTrial: new PointsTable({}),
};
const dropScorePolicy: DropScorePolicy = {
strategy: 'none',
};
const championship: ChampionshipConfig = {
id: 'driver-champ-club-default',
name: 'Driver Championship',
type: 'driver' as ChampionshipType,
sessionTypes,
pointsTableBySessionType,
dropScorePolicy,
};
return {
id: `lsc-${seasonId}-club-default`,
seasonId,
scoringPresetId: 'club-default',
championships: [championship],
};
},
},
{
id: 'endurance-main-double',
name: 'Endurance weekend',
description:
'Single main endurance race with double points and a simple drop policy.',
primaryChampionshipType: 'driver',
dropPolicySummary: 'Best 4 results of 6 count towards the championship.',
sessionSummary: 'Main race only',
bonusSummary: 'No bonus points.',
createConfig: ({ seasonId }) => {
const sessionTypes: SessionType[] = ['main'];
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
sprint: new PointsTable({}),
main: enduranceMainPoints,
practice: new PointsTable({}),
qualifying: new PointsTable({}),
q1: new PointsTable({}),
q2: new PointsTable({}),
q3: new PointsTable({}),
timeTrial: new PointsTable({}),
};
const dropScorePolicy: DropScorePolicy = {
strategy: 'bestNResults',
count: 4,
};
const championship: ChampionshipConfig = {
id: 'driver-champ-endurance-main-double',
name: 'Driver Championship',
type: 'driver' as ChampionshipType,
sessionTypes,
pointsTableBySessionType,
dropScorePolicy,
};
return {
id: `lsc-${seasonId}-endurance-main-double`,
seasonId,
scoringPresetId: 'endurance-main-double',
championships: [championship],
};
},
},
];
export function listLeagueScoringPresets(): LeagueScoringPreset[] {
return [...leagueScoringPresets];
}
export function getLeagueScoringPresetById(
id: string,
): LeagueScoringPreset | undefined {
return leagueScoringPresets.find((preset) => preset.id === id);
}
export class InMemoryGameRepository implements IGameRepository { export class InMemoryGameRepository implements IGameRepository {
private games: Game[]; private games: Game[];
@@ -49,6 +281,11 @@ export class InMemorySeasonRepository implements ISeasonRepository {
return this.seasons.filter((s) => s.leagueId === leagueId); return this.seasons.filter((s) => s.leagueId === leagueId);
} }
async create(season: Season): Promise<Season> {
this.seasons.push(season);
return season;
}
seed(season: Season): void { seed(season: Season): void {
this.seasons.push(season); this.seasons.push(season);
} }
@@ -67,6 +304,18 @@ export class InMemoryLeagueScoringConfigRepository
return this.configs.find((c) => c.seasonId === seasonId) ?? null; return this.configs.find((c) => c.seasonId === seasonId) ?? null;
} }
async save(config: LeagueScoringConfig): Promise<LeagueScoringConfig> {
const existingIndex = this.configs.findIndex(
(c) => c.id === config.id,
);
if (existingIndex >= 0) {
this.configs[existingIndex] = config;
} else {
this.configs.push(config);
}
return config;
}
seed(config: LeagueScoringConfig): void { seed(config: LeagueScoringConfig): void {
this.configs.push(config); this.configs.push(config);
} }
@@ -99,7 +348,7 @@ export class InMemoryChampionshipStandingRepository
} }
} }
export function createF1DemoScoringSetup(params: { export function createSprintMainDemoScoringSetup(params: {
leagueId: string; leagueId: string;
seasonId?: string; seasonId?: string;
}): { }): {
@@ -111,7 +360,7 @@ export function createF1DemoScoringSetup(params: {
championshipId: string; championshipId: string;
} { } {
const { leagueId } = params; const { leagueId } = params;
const seasonId = params.seasonId ?? 'season-f1-demo'; const seasonId = params.seasonId ?? 'season-sprint-main-demo';
const championshipId = 'driver-champ'; const championshipId = 'driver-champ';
const game = Game.create({ id: 'iracing', name: 'iRacing' }); const game = Game.create({ id: 'iracing', name: 'iRacing' });
@@ -120,7 +369,7 @@ export function createF1DemoScoringSetup(params: {
id: seasonId, id: seasonId,
leagueId, leagueId,
gameId: game.id, gameId: game.id,
name: 'F1-Style Demo Season', name: 'Sprint + Main Demo Season',
year: 2025, year: 2025,
order: 1, order: 1,
status: 'active', status: 'active',
@@ -128,81 +377,14 @@ export function createF1DemoScoringSetup(params: {
endDate: new Date('2025-12-31'), endDate: new Date('2025-12-31'),
}); });
const mainPoints = new PointsTable({ const preset = getLeagueScoringPresetById('sprint-main-driver');
1: 25, if (!preset) {
2: 18, throw new Error('Missing sprint-main-driver scoring preset');
3: 15, }
4: 12,
5: 10,
6: 8,
7: 6,
8: 4,
9: 2,
10: 1,
});
const sprintPoints = new PointsTable({ const leagueScoringConfig: LeagueScoringConfig = preset.createConfig({
1: 8,
2: 7,
3: 6,
4: 5,
5: 4,
6: 3,
7: 2,
8: 1,
});
const fastestLapBonus: BonusRule = {
id: 'fastest-lap-main',
type: 'fastestLap',
points: 1,
requiresFinishInTopN: 10,
};
const sessionTypes: SessionType[] = ['sprint', 'main'];
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
sprint: sprintPoints,
main: mainPoints,
practice: new PointsTable({}),
qualifying: new PointsTable({}),
q1: new PointsTable({}),
q2: new PointsTable({}),
q3: new PointsTable({}),
timeTrial: new PointsTable({}),
};
const bonusRulesBySessionType: Record<SessionType, BonusRule[]> = {
sprint: [],
main: [fastestLapBonus],
practice: [],
qualifying: [],
q1: [],
q2: [],
q3: [],
timeTrial: [],
};
const dropScorePolicy: DropScorePolicy = {
strategy: 'bestNResults',
count: 6,
};
const championship: ChampionshipConfig = {
id: championshipId,
name: 'Driver Championship',
type: 'driver' as ChampionshipType,
sessionTypes,
pointsTableBySessionType,
bonusRulesBySessionType,
dropScorePolicy,
};
const leagueScoringConfig: LeagueScoringConfig = {
id: 'lsc-f1-demo',
seasonId: season.id, seasonId: season.id,
championships: [championship], });
};
const gameRepo = new InMemoryGameRepository([game]); const gameRepo = new InMemoryGameRepository([game]);
const seasonRepo = new InMemorySeasonRepository([season]); const seasonRepo = new InMemorySeasonRepository([season]);

View File

@@ -85,9 +85,9 @@ function createDrivers(count: number): Driver[] {
function createLeagues(ownerIds: string[]): League[] { function createLeagues(ownerIds: string[]): League[] {
const leagueNames = [ const leagueNames = [
'Global GT Masters', 'GridPilot Sprint Series',
'Midnight Endurance Series', 'GridPilot Endurance Cup',
'Virtual Touring Cup', 'GridPilot Club Ladder',
'Sprint Challenge League', 'Sprint Challenge League',
'Club Racers Collective', 'Club Racers Collective',
'Sim Racing Alliance', 'Sim Racing Alliance',
@@ -104,12 +104,29 @@ function createLeagues(ownerIds: string[]): League[] {
const ownerId = pickOne(ownerIds); const ownerId = pickOne(ownerIds);
const maxDriversOptions = [24, 32, 48, 64]; const maxDriversOptions = [24, 32, 48, 64];
const settings = { let settings = {
pointsSystem: faker.helpers.arrayElement(['f1-2024', 'indycar']), pointsSystem: faker.helpers.arrayElement(['f1-2024', 'indycar']),
sessionDuration: faker.helpers.arrayElement([45, 60, 90, 120]), sessionDuration: faker.helpers.arrayElement([45, 60, 90, 120]),
qualifyingFormat: faker.helpers.arrayElement(['open', 'single-lap']), qualifyingFormat: faker.helpers.arrayElement(['open', 'single-lap']),
maxDrivers: faker.helpers.arrayElement(maxDriversOptions), maxDrivers: faker.helpers.arrayElement(maxDriversOptions),
}; } as const;
if (i === 0) {
settings = {
...settings,
maxDrivers: 24,
};
} else if (i === 1) {
settings = {
...settings,
maxDrivers: 24,
};
} else if (i === 2) {
settings = {
...settings,
maxDrivers: 40,
};
}
const socialLinks = const socialLinks =
i === 0 i === 0
@@ -615,4 +632,60 @@ export function getLatestResults(limit?: number): readonly RaceWithResultsDTO[]
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime()); .sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime());
return typeof limit === 'number' ? sorted.slice(0, limit) : sorted; return typeof limit === 'number' ? sorted.slice(0, limit) : sorted;
}
/**
* Demo league archetype helper for seeding structure and scoring.
*
* This keeps archetype knowledge local to the static racing seed while allowing
* demo infrastructure (e.g. DI container) to attach seasons and scoring configs.
*/
export type DemoLeagueArchetype =
| {
id: 'sprint-series';
name: 'GridPilot Sprint Series';
structure: { mode: 'solo'; maxDrivers: 24 };
scoringPresetId: 'sprint-main-driver';
}
| {
id: 'endurance-cup';
name: 'GridPilot Endurance Cup';
structure: { mode: 'fixedTeams'; maxTeams: 12; driversPerTeam: 2 };
scoringPresetId: 'endurance-main-double';
}
| {
id: 'club-ladder';
name: 'GridPilot Club Ladder';
structure: { mode: 'solo'; maxDrivers: 40 };
scoringPresetId: 'club-default';
};
export function getDemoLeagueArchetypeByName(
leagueName: string,
): DemoLeagueArchetype | undefined {
switch (leagueName) {
case 'GridPilot Sprint Series':
return {
id: 'sprint-series',
name: 'GridPilot Sprint Series',
structure: { mode: 'solo', maxDrivers: 24 },
scoringPresetId: 'sprint-main-driver',
};
case 'GridPilot Endurance Cup':
return {
id: 'endurance-cup',
name: 'GridPilot Endurance Cup',
structure: { mode: 'fixedTeams', maxTeams: 12, driversPerTeam: 2 },
scoringPresetId: 'endurance-main-double',
};
case 'GridPilot Club Ladder':
return {
id: 'club-ladder',
name: 'GridPilot Club Ladder',
structure: { mode: 'solo', maxDrivers: 40 },
scoringPresetId: 'club-default',
};
default:
return undefined;
}
} }