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 LeagueAdmin from '@/components/leagues/LeagueAdmin';
import StandingsTable from '@/components/leagues/StandingsTable';
import LeagueScoringTab from '@/components/leagues/LeagueScoringTab';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import { League } from '@gridpilot/racing/domain/entities/League';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
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 {
getLeagueRepository,
getRaceRepository,
getDriverRepository,
getGetLeagueDriverSeasonStatsQuery,
getGetLeagueScoringConfigQuery,
getDriverStats,
getAllDriverRankings,
} from '@/lib/di-container';
@@ -36,9 +39,12 @@ export default function LeagueDetailPage() {
const [owner, setOwner] = useState<Driver | null>(null);
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
const [loading, setLoading] = useState(true);
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 currentDriverId = useEffectiveDriverId();
@@ -71,6 +77,11 @@ export default function LeagueDetailPage() {
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
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
const allDrivers = await driverRepo.findAll();
const driverDtos: DriverDTO[] = allDrivers
@@ -100,9 +111,10 @@ export default function LeagueDetailPage() {
initialTab === 'overview' ||
initialTab === 'schedule' ||
initialTab === 'standings' ||
initialTab === 'scoring' ||
initialTab === 'admin'
) {
setActiveTab(initialTab);
setActiveTab(initialTab as typeof activeTab);
}
}, [searchParams]);
@@ -231,6 +243,16 @@ export default function LeagueDetailPage() {
>
Standings
</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 && (
<button
onClick={() => setActiveTab('admin')}
@@ -266,22 +288,36 @@ export default function LeagueDetailPage() {
</div>
<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>
<label className="text-sm text-gray-500">Points System</label>
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
Structure
</h4>
<p className="text-gray-200">
Solo {league.settings.maxDrivers ?? 32} drivers
</p>
</div>
<div>
<label className="text-sm text-gray-500">Session Duration</label>
<p className="text-white">{league.settings.sessionDuration} minutes</p>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
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>
<label className="text-sm text-gray-500">Qualifying Format</label>
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
Scoring & drops
</h4>
<p className="text-gray-200">
{league.settings.pointsSystem.toUpperCase()}
</p>
</div>
</div>
</div>
@@ -439,6 +475,23 @@ export default function LeagueDetailPage() {
</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 && (
<LeagueAdmin
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 { useRouter } from 'next/navigation';
import LeagueCard from '@/components/leagues/LeagueCard';
import CreateLeagueForm from '@/components/leagues/CreateLeagueForm';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Input from '@/components/ui/Input';
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
import { getGetAllLeaguesWithCapacityQuery } from '@/lib/di-container';
import type { LeagueSummaryDTO } from '@gridpilot/racing/application/dto/LeagueSummaryDTO';
import { getGetAllLeaguesWithCapacityAndScoringQuery } from '@/lib/di-container';
export default function LeaguesPage() {
const router = useRouter();
const [leagues, setLeagues] = useState<LeagueDTO[]>([]);
const [leagues, setLeagues] = useState<LeagueSummaryDTO[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
@@ -24,7 +22,7 @@ export default function LeaguesPage() {
const loadLeagues = async () => {
try {
const query = getGetAllLeaguesWithCapacityQuery();
const query = getGetAllLeaguesWithCapacityAndScoringQuery();
const allLeagues = await query.execute();
setLeagues(allLeagues);
} catch (error) {
@@ -78,24 +76,12 @@ export default function LeaguesPage() {
<Button
variant="primary"
onClick={() => setShowCreateForm(!showCreateForm)}
onClick={() => router.push('/leagues/create')}
>
{showCreateForm ? 'Cancel' : 'Create League'}
Create League
</Button>
</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 && (
<Card className="mb-8">
<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,
getAllDriverRankings,
getDriverRepository,
getGetLeagueFullConfigQuery,
} 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 type { MembershipRole } from '@/lib/leagueMembership';
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 [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'disputes'>('members');
const [rejectReason, setRejectReason] = useState('');
const [configForm, setConfigForm] = useState<LeagueConfigFormModel | null>(null);
const [configLoading, setConfigLoading] = useState(false);
const loadJoinRequests = useCallback(async () => {
setLoading(true);
@@ -93,6 +102,23 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
loadOwner();
}, [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) => {
try {
const membershipRepo = getLeagueMembershipRepository();
@@ -464,113 +490,74 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
<Card>
<h2 className="text-xl font-semibold text-white mb-4">League Settings</h2>
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
League Name
</label>
<p className="text-white">{league.name}</p>
</div>
{configLoading && !configForm ? (
<div className="py-6 text-sm text-gray-400">Loading configuration</div>
) : configForm ? (
<div className="space-y-8">
<LeagueBasicsSection form={configForm} readOnly />
<LeagueStructureSection form={configForm} readOnly />
<LeagueTimingsSection form={configForm} readOnly />
<LeagueScoringSection form={configForm} presets={[]} readOnly />
<LeagueDropSection form={configForm} readOnly />
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<p className="text-white">{league.description}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2 border-t border-charcoal-outline">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div>
<label className="text-sm text-gray-500">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>
</div>
<div>
<label className="text-sm text-gray-500">Points System</label>
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
</div>
<div>
<label className="text-sm text-gray-500">Qualifying Format</label>
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/60 p-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-200 mb-1">
At a glance
</h3>
<p className="text-xs text-gray-300">
<span className="font-semibold text-gray-200">Structure:</span>{' '}
{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>
{league.socialLinks && (
<div className="pt-4 border-t border-charcoal-outline space-y-2">
<h3 className="text-sm font-medium text-gray-300">Social Links</h3>
<div className="space-y-1 text-sm">
{league.socialLinks.discordUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">Discord</span>
<a
href={league.socialLinks.discordUrl}
target="_blank"
rel="noreferrer"
className="text-primary-blue hover:underline break-all"
>
{league.socialLinks.discordUrl}
</a>
</div>
)}
{league.socialLinks.youtubeUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">YouTube</span>
<a
href={league.socialLinks.youtubeUrl}
target="_blank"
rel="noreferrer"
className="text-red-400 hover:underline break-all"
>
{league.socialLinks.youtubeUrl}
</a>
</div>
)}
{league.socialLinks.websiteUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">Website</span>
<a
href={league.socialLinks.websiteUrl}
target="_blank"
rel="noreferrer"
className="text-gray-100 hover:underline break-all"
>
{league.socialLinks.websiteUrl}
</a>
</div>
)}
{!league.socialLinks.discordUrl &&
!league.socialLinks.youtubeUrl &&
!league.socialLinks.websiteUrl && (
<p className="text-gray-500">
No social links configured for this league in the alpha demo.
</p>
)}
</div>
</div>
)}
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-300">League Owner</h3>
{ownerSummary ? (
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
/>
) : (
<p className="text-sm text-gray-500">Loading owner details...</p>
)}
</div>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-300">League Owner</h3>
{ownerSummary ? (
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
/>
) : (
<p className="text-sm text-gray-500">Loading owner details...</p>
)}
<div className="pt-4 border-t border-charcoal-outline">
<p className="text-sm text-gray-400">
League settings editing is alpha-only and changes are not persisted yet.
</p>
</div>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<p className="text-sm text-gray-400">
League settings editing is alpha-only and changes are not persisted yet.
</p>
) : (
<div className="py-6 text-sm text-gray-500">
Unable to load league configuration for this demo league.
</div>
</div>
)}
</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 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 { getLeagueCoverClasses } from '@/lib/leagueCovers';
import { getImageService } from '@/lib/di-container';
interface LeagueCardProps {
league: LeagueDTO;
league: LeagueSummaryDTO;
onClick?: () => void;
}
@@ -57,6 +57,22 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
<p className="text-gray-400 text-sm line-clamp-2">
{league.description}
</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 flex-col text-xs text-gray-500">
@@ -70,19 +86,55 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
</Link>
</span>
<span className="mt-1 text-gray-400">
Slots:{' '}
Drivers:{' '}
<span className="text-white font-medium">
{typeof league.usedSlots === 'number' ? league.usedSlots : '—'}
{typeof league.usedDriverSlots === 'number'
? league.usedDriverSlots
: '—'}
</span>
{' / '}
<span className="text-gray-300">
{league.settings.maxDrivers ?? '—'}
</span>{' '}
used
{league.maxDrivers ?? '—'}
</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 className="text-xs text-primary-blue font-medium">
{league.settings.pointsSystem.toUpperCase()}
<div className="flex flex-col items-end text-xs text-gray-400">
{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>

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) {
return (
<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}
>
{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 { Result } from '@gridpilot/racing/domain/entities/Result';
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 { 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 { IStandingRepository } from '@gridpilot/racing/domain/repositories/IStandingRepository';
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 {
ITeamRepository,
ITeamMembershipRepository,
@@ -34,6 +39,13 @@ import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/reposit
import { InMemoryResultRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryResultRepository';
import { InMemoryStandingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryStandingRepository';
import { InMemoryPenaltyRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryPenaltyRepository';
import {
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 { InMemoryTeamMembershipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamMembershipRepository';
import { InMemoryRaceRegistrationRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRegistrationRepository';
@@ -58,13 +70,28 @@ import {
GetLeagueStandingsQuery,
GetLeagueDriverSeasonStatsQuery,
GetAllLeaguesWithCapacityQuery,
GetAllLeaguesWithCapacityAndScoringQuery,
ListLeagueScoringPresetsQuery,
GetLeagueScoringConfigQuery,
CreateLeagueWithSeasonAndScoringUseCase,
GetLeagueFullConfigQuery,
} 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 {
InMemoryFeedRepository,
InMemorySocialGraphRepository,
} from '@gridpilot/social/infrastructure/inmemory/InMemorySocialAndFeed';
import { DemoImageServiceAdapter } from '@gridpilot/demo-infrastructure';
import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
/**
* Seed data for development
@@ -138,6 +165,10 @@ class DIContainer {
private _teamMembershipRepository: ITeamMembershipRepository;
private _raceRegistrationRepository: IRaceRegistrationRepository;
private _leagueMembershipRepository: ILeagueMembershipRepository;
private _gameRepository: IGameRepository;
private _seasonRepository: ISeasonRepository;
private _leagueScoringConfigRepository: ILeagueScoringConfigRepository;
private _leagueScoringPresetProvider: LeagueScoringPresetProvider;
private _feedRepository: IFeedRepository;
private _socialRepository: ISocialGraphRepository;
private _imageService: ImageServicePort;
@@ -151,6 +182,13 @@ class DIContainer {
private _getLeagueStandingsQuery: GetLeagueStandingsQuery;
private _getLeagueDriverSeasonStatsQuery: GetLeagueDriverSeasonStatsQuery;
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 _joinTeamUseCase: JoinTeamUseCase;
@@ -190,10 +228,51 @@ class DIContainer {
// Race registrations (start empty; populated via use-cases)
this._raceRegistrationRepository = new InMemoryRaceRegistrationRepository();
// Penalties (seeded in-memory adapter)
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
const seededMemberships: LeagueMembership[] = seedData.memberships.map((m) => ({
leagueId: m.leagueId,
@@ -371,6 +450,46 @@ class DIContainer {
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._teamRepository,
this._teamMembershipRepository,
@@ -464,6 +583,22 @@ class DIContainer {
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 {
return this._joinLeagueUseCase;
}
@@ -496,6 +631,31 @@ class DIContainer {
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 {
return this._createTeamUseCase;
}
@@ -628,6 +788,31 @@ export function getGetAllLeaguesWithCapacityQuery(): GetAllLeaguesWithCapacityQu
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 {
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/GetLeagueDriverSeasonStatsQuery';
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/CreateLeagueWithSeasonAndScoringUseCase';
export * from './use-cases/GetLeagueFullConfigQuery';
export * from './use-cases/PreviewLeagueScheduleQuery';
// Re-export domain types for legacy callers (type-only)
export type {
LeagueMembership,
MembershipRole,
MembershipStatus,
JoinRequest,
} from '../domain/entities/LeagueMembership';
// Re-export domain types for legacy callers (type-only)
export type {
LeagueMembership,
MembershipRole,
MembershipStatus,
JoinRequest,
} from '../domain/entities/LeagueMembership';
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 { StandingDTO } from './dto/StandingDTO';
export type { LeagueDriverSeasonStatsDTO } from './dto/LeagueDriverSeasonStatsDTO';
export type {
LeagueScheduleDTO,
LeagueSchedulePreviewDTO,
} from './dto/LeagueScheduleDTO';
export type {
ChampionshipStandingsDTO,
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 {
id: 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[];
}

View File

@@ -2,4 +2,5 @@ import type { LeagueScoringConfig } from '../entities/LeagueScoringConfig';
export interface ILeagueScoringConfigRepository {
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 {
findById(id: string): Promise<Season | null>;
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 { 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 {
private games: Game[];
@@ -49,6 +281,11 @@ export class InMemorySeasonRepository implements ISeasonRepository {
return this.seasons.filter((s) => s.leagueId === leagueId);
}
async create(season: Season): Promise<Season> {
this.seasons.push(season);
return season;
}
seed(season: Season): void {
this.seasons.push(season);
}
@@ -67,6 +304,18 @@ export class InMemoryLeagueScoringConfigRepository
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 {
this.configs.push(config);
}
@@ -99,7 +348,7 @@ export class InMemoryChampionshipStandingRepository
}
}
export function createF1DemoScoringSetup(params: {
export function createSprintMainDemoScoringSetup(params: {
leagueId: string;
seasonId?: string;
}): {
@@ -111,7 +360,7 @@ export function createF1DemoScoringSetup(params: {
championshipId: string;
} {
const { leagueId } = params;
const seasonId = params.seasonId ?? 'season-f1-demo';
const seasonId = params.seasonId ?? 'season-sprint-main-demo';
const championshipId = 'driver-champ';
const game = Game.create({ id: 'iracing', name: 'iRacing' });
@@ -120,7 +369,7 @@ export function createF1DemoScoringSetup(params: {
id: seasonId,
leagueId,
gameId: game.id,
name: 'F1-Style Demo Season',
name: 'Sprint + Main Demo Season',
year: 2025,
order: 1,
status: 'active',
@@ -128,81 +377,14 @@ export function createF1DemoScoringSetup(params: {
endDate: new Date('2025-12-31'),
});
const mainPoints = new PointsTable({
1: 25,
2: 18,
3: 15,
4: 12,
5: 10,
6: 8,
7: 6,
8: 4,
9: 2,
10: 1,
});
const preset = getLeagueScoringPresetById('sprint-main-driver');
if (!preset) {
throw new Error('Missing sprint-main-driver scoring preset');
}
const sprintPoints = new PointsTable({
1: 8,
2: 7,
3: 6,
4: 5,
5: 4,
6: 3,
7: 2,
8: 1,
});
const fastestLapBonus: BonusRule = {
id: 'fastest-lap-main',
type: 'fastestLap',
points: 1,
requiresFinishInTopN: 10,
};
const sessionTypes: SessionType[] = ['sprint', 'main'];
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
sprint: sprintPoints,
main: mainPoints,
practice: new PointsTable({}),
qualifying: new PointsTable({}),
q1: new PointsTable({}),
q2: new PointsTable({}),
q3: new PointsTable({}),
timeTrial: new PointsTable({}),
};
const bonusRulesBySessionType: Record<SessionType, BonusRule[]> = {
sprint: [],
main: [fastestLapBonus],
practice: [],
qualifying: [],
q1: [],
q2: [],
q3: [],
timeTrial: [],
};
const dropScorePolicy: DropScorePolicy = {
strategy: 'bestNResults',
count: 6,
};
const championship: ChampionshipConfig = {
id: championshipId,
name: 'Driver Championship',
type: 'driver' as ChampionshipType,
sessionTypes,
pointsTableBySessionType,
bonusRulesBySessionType,
dropScorePolicy,
};
const leagueScoringConfig: LeagueScoringConfig = {
id: 'lsc-f1-demo',
const leagueScoringConfig: LeagueScoringConfig = preset.createConfig({
seasonId: season.id,
championships: [championship],
};
});
const gameRepo = new InMemoryGameRepository([game]);
const seasonRepo = new InMemorySeasonRepository([season]);

View File

@@ -85,9 +85,9 @@ function createDrivers(count: number): Driver[] {
function createLeagues(ownerIds: string[]): League[] {
const leagueNames = [
'Global GT Masters',
'Midnight Endurance Series',
'Virtual Touring Cup',
'GridPilot Sprint Series',
'GridPilot Endurance Cup',
'GridPilot Club Ladder',
'Sprint Challenge League',
'Club Racers Collective',
'Sim Racing Alliance',
@@ -104,12 +104,29 @@ function createLeagues(ownerIds: string[]): League[] {
const ownerId = pickOne(ownerIds);
const maxDriversOptions = [24, 32, 48, 64];
const settings = {
let settings = {
pointsSystem: faker.helpers.arrayElement(['f1-2024', 'indycar']),
sessionDuration: faker.helpers.arrayElement([45, 60, 90, 120]),
qualifyingFormat: faker.helpers.arrayElement(['open', 'single-lap']),
maxDrivers: faker.helpers.arrayElement(maxDriversOptions),
};
} 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 =
i === 0
@@ -615,4 +632,60 @@ export function getLatestResults(limit?: number): readonly RaceWithResultsDTO[]
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime());
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;
}
}