wip
This commit is contained in:
94
apps/website/app/api/leagues/schedule-preview/route.ts
Normal file
94
apps/website/app/api/leagues/schedule-preview/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
15
apps/website/app/leagues/create/page.tsx
Normal file
15
apps/website/app/leagues/create/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
587
apps/website/components/leagues/CreateLeagueWizard.tsx
Normal file
587
apps/website/components/leagues/CreateLeagueWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
130
apps/website/components/leagues/LeagueBasicsSection.tsx
Normal file
130
apps/website/components/leagues/LeagueBasicsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
137
apps/website/components/leagues/LeagueDropSection.tsx
Normal file
137
apps/website/components/leagues/LeagueDropSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
apps/website/components/leagues/LeagueReviewSummary.tsx
Normal file
210
apps/website/components/leagues/LeagueReviewSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
489
apps/website/components/leagues/LeagueScoringSection.tsx
Normal file
489
apps/website/components/leagues/LeagueScoringSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
apps/website/components/leagues/LeagueScoringTab.tsx
Normal file
190
apps/website/components/leagues/LeagueScoringTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
apps/website/components/leagues/LeagueStructureSection.tsx
Normal file
246
apps/website/components/leagues/LeagueStructureSection.tsx
Normal 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 & 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 20–30
|
||||
</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 2–3 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>
|
||||
);
|
||||
}
|
||||
751
apps/website/components/leagues/LeagueTimingsSection.tsx
Normal file
751
apps/website/components/leagues/LeagueTimingsSection.tsx
Normal 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 don’t 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
71
apps/website/components/ui/DurationField.tsx
Normal file
71
apps/website/components/ui/DurationField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
apps/website/components/ui/PresetCard.tsx
Normal file
122
apps/website/components/ui/PresetCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
apps/website/components/ui/RangeField.tsx
Normal file
130
apps/website/components/ui/RangeField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
apps/website/components/ui/SegmentedControl.tsx
Normal file
68
apps/website/components/ui/SegmentedControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
60
packages/racing/application/dto/LeagueConfigFormDTO.ts
Normal file
60
packages/racing/application/dto/LeagueConfigFormDTO.ts
Normal 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;
|
||||
}
|
||||
114
packages/racing/application/dto/LeagueScheduleDTO.ts
Normal file
114
packages/racing/application/dto/LeagueScheduleDTO.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
20
packages/racing/application/dto/LeagueScoringConfigDTO.ts
Normal file
20
packages/racing/application/dto/LeagueScoringConfigDTO.ts
Normal 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[];
|
||||
}
|
||||
41
packages/racing/application/dto/LeagueSummaryDTO.ts
Normal file
41
packages/racing/application/dto/LeagueSummaryDTO.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -2,4 +2,5 @@ import type { LeagueScoringConfig } from '../entities/LeagueScoringConfig';
|
||||
|
||||
export interface ILeagueScoringConfigRepository {
|
||||
findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null>;
|
||||
save(config: LeagueScoringConfig): Promise<LeagueScoringConfig>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
175
packages/racing/domain/services/SeasonScheduleGenerator.ts
Normal file
175
packages/racing/domain/services/SeasonScheduleGenerator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
packages/racing/domain/value-objects/LeagueTimezone.ts
Normal file
14
packages/racing/domain/value-objects/LeagueTimezone.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
34
packages/racing/domain/value-objects/RaceTimeOfDay.ts
Normal file
34
packages/racing/domain/value-objects/RaceTimeOfDay.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
53
packages/racing/domain/value-objects/RecurrenceStrategy.ts
Normal file
53
packages/racing/domain/value-objects/RecurrenceStrategy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
20
packages/racing/domain/value-objects/ScheduledRaceSlot.ts
Normal file
20
packages/racing/domain/value-objects/ScheduledRaceSlot.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
36
packages/racing/domain/value-objects/SeasonSchedule.ts
Normal file
36
packages/racing/domain/value-objects/SeasonSchedule.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
25
packages/racing/domain/value-objects/Weekday.ts
Normal file
25
packages/racing/domain/value-objects/Weekday.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
23
packages/racing/domain/value-objects/WeekdaySet.ts
Normal file
23
packages/racing/domain/value-objects/WeekdaySet.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user