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 LeagueSchedule from '@/components/leagues/LeagueSchedule';
|
||||||
import LeagueAdmin from '@/components/leagues/LeagueAdmin';
|
import LeagueAdmin from '@/components/leagues/LeagueAdmin';
|
||||||
import StandingsTable from '@/components/leagues/StandingsTable';
|
import StandingsTable from '@/components/leagues/StandingsTable';
|
||||||
|
import LeagueScoringTab from '@/components/leagues/LeagueScoringTab';
|
||||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
||||||
import { League } from '@gridpilot/racing/domain/entities/League';
|
import { League } from '@gridpilot/racing/domain/entities/League';
|
||||||
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
||||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||||
import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO';
|
import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO';
|
||||||
|
import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO';
|
||||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||||
import {
|
import {
|
||||||
getLeagueRepository,
|
getLeagueRepository,
|
||||||
getRaceRepository,
|
getRaceRepository,
|
||||||
getDriverRepository,
|
getDriverRepository,
|
||||||
getGetLeagueDriverSeasonStatsQuery,
|
getGetLeagueDriverSeasonStatsQuery,
|
||||||
|
getGetLeagueScoringConfigQuery,
|
||||||
getDriverStats,
|
getDriverStats,
|
||||||
getAllDriverRankings,
|
getAllDriverRankings,
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
@@ -36,9 +39,12 @@ export default function LeagueDetailPage() {
|
|||||||
const [owner, setOwner] = useState<Driver | null>(null);
|
const [owner, setOwner] = useState<Driver | null>(null);
|
||||||
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
|
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
|
||||||
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
|
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
|
||||||
|
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'schedule' | 'standings' | 'admin'>('overview');
|
const [activeTab, setActiveTab] = useState<
|
||||||
|
'overview' | 'schedule' | 'standings' | 'scoring' | 'admin'
|
||||||
|
>('overview');
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
@@ -71,6 +77,11 @@ export default function LeagueDetailPage() {
|
|||||||
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
|
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
|
||||||
setStandings(leagueStandings);
|
setStandings(leagueStandings);
|
||||||
|
|
||||||
|
// Load scoring configuration for the active season
|
||||||
|
const getLeagueScoringConfigQuery = getGetLeagueScoringConfigQuery();
|
||||||
|
const scoring = await getLeagueScoringConfigQuery.execute({ leagueId });
|
||||||
|
setScoringConfig(scoring);
|
||||||
|
|
||||||
// Load all drivers for standings and map to DTOs for UI components
|
// Load all drivers for standings and map to DTOs for UI components
|
||||||
const allDrivers = await driverRepo.findAll();
|
const allDrivers = await driverRepo.findAll();
|
||||||
const driverDtos: DriverDTO[] = allDrivers
|
const driverDtos: DriverDTO[] = allDrivers
|
||||||
@@ -100,9 +111,10 @@ export default function LeagueDetailPage() {
|
|||||||
initialTab === 'overview' ||
|
initialTab === 'overview' ||
|
||||||
initialTab === 'schedule' ||
|
initialTab === 'schedule' ||
|
||||||
initialTab === 'standings' ||
|
initialTab === 'standings' ||
|
||||||
|
initialTab === 'scoring' ||
|
||||||
initialTab === 'admin'
|
initialTab === 'admin'
|
||||||
) {
|
) {
|
||||||
setActiveTab(initialTab);
|
setActiveTab(initialTab as typeof activeTab);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
@@ -231,6 +243,16 @@ export default function LeagueDetailPage() {
|
|||||||
>
|
>
|
||||||
Standings
|
Standings
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('scoring')}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||||
|
activeTab === 'scoring'
|
||||||
|
? 'bg-primary-blue text-white'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Scoring
|
||||||
|
</button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('admin')}
|
onClick={() => setActiveTab('admin')}
|
||||||
@@ -266,22 +288,36 @@ export default function LeagueDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 border-t border-charcoal-outline">
|
<div className="pt-4 border-t border-charcoal-outline">
|
||||||
<h3 className="text-white font-medium mb-3">League Settings</h3>
|
<h3 className="text-white font-medium mb-3">At a glance</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-gray-500">Points System</label>
|
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||||
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
|
Structure
|
||||||
|
</h4>
|
||||||
|
<p className="text-gray-200">
|
||||||
|
Solo • {league.settings.maxDrivers ?? 32} drivers
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-gray-500">Session Duration</label>
|
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||||
<p className="text-white">{league.settings.sessionDuration} minutes</p>
|
Schedule
|
||||||
|
</h4>
|
||||||
|
<p className="text-gray-200">
|
||||||
|
{`? rounds • 30 min Qualifying • ${
|
||||||
|
typeof league.settings.sessionDuration === 'number'
|
||||||
|
? league.settings.sessionDuration
|
||||||
|
: 40
|
||||||
|
} min Races`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-gray-500">Qualifying Format</label>
|
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||||
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
|
Scoring & drops
|
||||||
|
</h4>
|
||||||
|
<p className="text-gray-200">
|
||||||
|
{league.settings.pointsSystem.toUpperCase()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -439,6 +475,23 @@ export default function LeagueDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'scoring' && (
|
||||||
|
<Card>
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-4">Scoring</h2>
|
||||||
|
<LeagueScoringTab
|
||||||
|
scoringConfig={scoringConfig}
|
||||||
|
practiceMinutes={20}
|
||||||
|
qualifyingMinutes={30}
|
||||||
|
sprintRaceMinutes={20}
|
||||||
|
mainRaceMinutes={
|
||||||
|
typeof league.settings.sessionDuration === 'number'
|
||||||
|
? league.settings.sessionDuration
|
||||||
|
: 40
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'admin' && isAdmin && (
|
{activeTab === 'admin' && isAdmin && (
|
||||||
<LeagueAdmin
|
<LeagueAdmin
|
||||||
league={league}
|
league={league}
|
||||||
|
|||||||
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 { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import LeagueCard from '@/components/leagues/LeagueCard';
|
import LeagueCard from '@/components/leagues/LeagueCard';
|
||||||
import CreateLeagueForm from '@/components/leagues/CreateLeagueForm';
|
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
|
import type { LeagueSummaryDTO } from '@gridpilot/racing/application/dto/LeagueSummaryDTO';
|
||||||
import { getGetAllLeaguesWithCapacityQuery } from '@/lib/di-container';
|
import { getGetAllLeaguesWithCapacityAndScoringQuery } from '@/lib/di-container';
|
||||||
|
|
||||||
export default function LeaguesPage() {
|
export default function LeaguesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [leagues, setLeagues] = useState<LeagueDTO[]>([]);
|
const [leagues, setLeagues] = useState<LeagueSummaryDTO[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [sortBy, setSortBy] = useState('name');
|
const [sortBy, setSortBy] = useState('name');
|
||||||
|
|
||||||
@@ -24,7 +22,7 @@ export default function LeaguesPage() {
|
|||||||
|
|
||||||
const loadLeagues = async () => {
|
const loadLeagues = async () => {
|
||||||
try {
|
try {
|
||||||
const query = getGetAllLeaguesWithCapacityQuery();
|
const query = getGetAllLeaguesWithCapacityAndScoringQuery();
|
||||||
const allLeagues = await query.execute();
|
const allLeagues = await query.execute();
|
||||||
setLeagues(allLeagues);
|
setLeagues(allLeagues);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -78,24 +76,12 @@ export default function LeaguesPage() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
onClick={() => router.push('/leagues/create')}
|
||||||
>
|
>
|
||||||
{showCreateForm ? 'Cancel' : 'Create League'}
|
Create League
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCreateForm && (
|
|
||||||
<Card className="mb-8 max-w-2xl mx-auto">
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xl font-semibold text-white mb-2">Create New League</h2>
|
|
||||||
<p className="text-gray-400 text-sm">
|
|
||||||
Experiment with different point systems
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<CreateLeagueForm />
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{leagues.length > 0 && (
|
{leagues.length > 0 && (
|
||||||
<Card className="mb-8">
|
<Card className="mb-8">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|||||||
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,
|
getDriverStats,
|
||||||
getAllDriverRankings,
|
getAllDriverRankings,
|
||||||
getDriverRepository,
|
getDriverRepository,
|
||||||
|
getGetLeagueFullConfigQuery,
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
|
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||||
|
import { LeagueBasicsSection } from './LeagueBasicsSection';
|
||||||
|
import { LeagueStructureSection } from './LeagueStructureSection';
|
||||||
|
import { LeagueScoringSection } from './LeagueScoringSection';
|
||||||
|
import { LeagueDropSection } from './LeagueDropSection';
|
||||||
|
import { LeagueTimingsSection } from './LeagueTimingsSection';
|
||||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||||
import type { MembershipRole } from '@/lib/leagueMembership';
|
import type { MembershipRole } from '@/lib/leagueMembership';
|
||||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||||
@@ -46,6 +53,8 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'disputes'>('members');
|
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'disputes'>('members');
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
const [configForm, setConfigForm] = useState<LeagueConfigFormModel | null>(null);
|
||||||
|
const [configLoading, setConfigLoading] = useState(false);
|
||||||
|
|
||||||
const loadJoinRequests = useCallback(async () => {
|
const loadJoinRequests = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -93,6 +102,23 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
|||||||
loadOwner();
|
loadOwner();
|
||||||
}, [league.ownerId]);
|
}, [league.ownerId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadConfig() {
|
||||||
|
setConfigLoading(true);
|
||||||
|
try {
|
||||||
|
const query = getGetLeagueFullConfigQuery();
|
||||||
|
const form = await query.execute({ leagueId: league.id });
|
||||||
|
setConfigForm(form);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load league config:', err);
|
||||||
|
} finally {
|
||||||
|
setConfigLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
}, [league.id]);
|
||||||
|
|
||||||
const handleApproveRequest = async (requestId: string) => {
|
const handleApproveRequest = async (requestId: string) => {
|
||||||
try {
|
try {
|
||||||
const membershipRepo = getLeagueMembershipRepository();
|
const membershipRepo = getLeagueMembershipRepository();
|
||||||
@@ -464,113 +490,74 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
|||||||
<Card>
|
<Card>
|
||||||
<h2 className="text-xl font-semibold text-white mb-4">League Settings</h2>
|
<h2 className="text-xl font-semibold text-white mb-4">League Settings</h2>
|
||||||
|
|
||||||
<div className="space-y-6">
|
{configLoading && !configForm ? (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="py-6 text-sm text-gray-400">Loading configuration…</div>
|
||||||
<div className="lg:col-span-2 space-y-4">
|
) : configForm ? (
|
||||||
<div>
|
<div className="space-y-8">
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<LeagueBasicsSection form={configForm} readOnly />
|
||||||
League Name
|
<LeagueStructureSection form={configForm} readOnly />
|
||||||
</label>
|
<LeagueTimingsSection form={configForm} readOnly />
|
||||||
<p className="text-white">{league.name}</p>
|
<LeagueScoringSection form={configForm} presets={[]} readOnly />
|
||||||
</div>
|
<LeagueDropSection form={configForm} readOnly />
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<p className="text-white">{league.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2 border-t border-charcoal-outline">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-gray-500">Season / Series</label>
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Season / Series
|
||||||
|
</label>
|
||||||
<p className="text-white">Alpha Demo Season</p>
|
<p className="text-white">Alpha Demo Season</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="rounded-lg border border-charcoal-outline bg-iron-gray/60 p-4 space-y-2">
|
||||||
<label className="text-sm text-gray-500">Points System</label>
|
<h3 className="text-sm font-semibold text-gray-200 mb-1">
|
||||||
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
|
At a glance
|
||||||
</div>
|
</h3>
|
||||||
<div>
|
<p className="text-xs text-gray-300">
|
||||||
<label className="text-sm text-gray-500">Qualifying Format</label>
|
<span className="font-semibold text-gray-200">Structure:</span>{' '}
|
||||||
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
|
{configForm.structure.mode === 'solo'
|
||||||
|
? `Solo • ${configForm.structure.maxDrivers} drivers`
|
||||||
|
: `Teams • ${configForm.structure.maxTeams ?? '—'} × ${
|
||||||
|
configForm.structure.driversPerTeam ?? '—'
|
||||||
|
} drivers (${configForm.structure.maxDrivers ?? '—'} total)`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-300">
|
||||||
|
<span className="font-semibold text-gray-200">Schedule:</span>{' '}
|
||||||
|
{`${configForm.timings.roundsPlanned ?? '?'} rounds • ${
|
||||||
|
configForm.timings.qualifyingMinutes
|
||||||
|
} min Qualifying • ${configForm.timings.mainRaceMinutes} min Race`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-300">
|
||||||
|
<span className="font-semibold text-gray-200">Scoring:</span>{' '}
|
||||||
|
{league.settings.pointsSystem.toUpperCase()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{league.socialLinks && (
|
<div className="space-y-3">
|
||||||
<div className="pt-4 border-t border-charcoal-outline space-y-2">
|
<h3 className="text-sm font-medium text-gray-300">League Owner</h3>
|
||||||
<h3 className="text-sm font-medium text-gray-300">Social Links</h3>
|
{ownerSummary ? (
|
||||||
<div className="space-y-1 text-sm">
|
<DriverSummaryPill
|
||||||
{league.socialLinks.discordUrl && (
|
driver={ownerSummary.driver}
|
||||||
<div className="flex items-center justify-between gap-3">
|
rating={ownerSummary.rating}
|
||||||
<span className="text-gray-400">Discord</span>
|
rank={ownerSummary.rank}
|
||||||
<a
|
/>
|
||||||
href={league.socialLinks.discordUrl}
|
) : (
|
||||||
target="_blank"
|
<p className="text-sm text-gray-500">Loading owner details...</p>
|
||||||
rel="noreferrer"
|
)}
|
||||||
className="text-primary-blue hover:underline break-all"
|
</div>
|
||||||
>
|
|
||||||
{league.socialLinks.discordUrl}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{league.socialLinks.youtubeUrl && (
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-gray-400">YouTube</span>
|
|
||||||
<a
|
|
||||||
href={league.socialLinks.youtubeUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-red-400 hover:underline break-all"
|
|
||||||
>
|
|
||||||
{league.socialLinks.youtubeUrl}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{league.socialLinks.websiteUrl && (
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-gray-400">Website</span>
|
|
||||||
<a
|
|
||||||
href={league.socialLinks.websiteUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-gray-100 hover:underline break-all"
|
|
||||||
>
|
|
||||||
{league.socialLinks.websiteUrl}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!league.socialLinks.discordUrl &&
|
|
||||||
!league.socialLinks.youtubeUrl &&
|
|
||||||
!league.socialLinks.websiteUrl && (
|
|
||||||
<p className="text-gray-500">
|
|
||||||
No social links configured for this league in the alpha demo.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="pt-4 border-t border-charcoal-outline">
|
||||||
<h3 className="text-sm font-medium text-gray-300">League Owner</h3>
|
<p className="text-sm text-gray-400">
|
||||||
{ownerSummary ? (
|
League settings editing is alpha-only and changes are not persisted yet.
|
||||||
<DriverSummaryPill
|
</p>
|
||||||
driver={ownerSummary.driver}
|
|
||||||
rating={ownerSummary.rating}
|
|
||||||
rank={ownerSummary.rank}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-500">Loading owner details...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div className="pt-4 border-t border-charcoal-outline">
|
<div className="py-6 text-sm text-gray-500">
|
||||||
<p className="text-sm text-gray-400">
|
Unable to load league configuration for this demo league.
|
||||||
League settings editing is alpha-only and changes are not persisted yet.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
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 Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
|
import type { LeagueSummaryDTO } from '@gridpilot/racing/application/dto/LeagueSummaryDTO';
|
||||||
import Card from '../ui/Card';
|
import Card from '../ui/Card';
|
||||||
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
|
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
|
||||||
import { getImageService } from '@/lib/di-container';
|
import { getImageService } from '@/lib/di-container';
|
||||||
|
|
||||||
interface LeagueCardProps {
|
interface LeagueCardProps {
|
||||||
league: LeagueDTO;
|
league: LeagueSummaryDTO;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +57,22 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
|
|||||||
<p className="text-gray-400 text-sm line-clamp-2">
|
<p className="text-gray-400 text-sm line-clamp-2">
|
||||||
{league.description}
|
{league.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{league.structureSummary && (
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{league.structureSummary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{league.scoringPatternSummary && (
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{league.scoringPatternSummary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{league.timingSummary && (
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{league.timingSummary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
|
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
|
||||||
<div className="flex flex-col text-xs text-gray-500">
|
<div className="flex flex-col text-xs text-gray-500">
|
||||||
@@ -70,19 +86,55 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 text-gray-400">
|
<span className="mt-1 text-gray-400">
|
||||||
Slots:{' '}
|
Drivers:{' '}
|
||||||
<span className="text-white font-medium">
|
<span className="text-white font-medium">
|
||||||
{typeof league.usedSlots === 'number' ? league.usedSlots : '—'}
|
{typeof league.usedDriverSlots === 'number'
|
||||||
|
? league.usedDriverSlots
|
||||||
|
: '—'}
|
||||||
</span>
|
</span>
|
||||||
{' / '}
|
{' / '}
|
||||||
<span className="text-gray-300">
|
<span className="text-gray-300">
|
||||||
{league.settings.maxDrivers ?? '—'}
|
{league.maxDrivers ?? '—'}
|
||||||
</span>{' '}
|
</span>
|
||||||
used
|
|
||||||
</span>
|
</span>
|
||||||
|
{typeof league.usedTeamSlots === 'number' ||
|
||||||
|
typeof league.maxTeams === 'number' ? (
|
||||||
|
<span className="mt-0.5 text-gray-400">
|
||||||
|
Teams:{' '}
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{typeof league.usedTeamSlots === 'number'
|
||||||
|
? league.usedTeamSlots
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
{' / '}
|
||||||
|
<span className="text-gray-300">
|
||||||
|
{league.maxTeams ?? '—'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-primary-blue font-medium">
|
<div className="flex flex-col items-end text-xs text-gray-400">
|
||||||
{league.settings.pointsSystem.toUpperCase()}
|
{league.scoring ? (
|
||||||
|
<>
|
||||||
|
<span className="text-primary-blue font-semibold">
|
||||||
|
{league.scoring.gameName}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5">
|
||||||
|
{league.scoring.primaryChampionshipType === 'driver'
|
||||||
|
? 'Driver championship'
|
||||||
|
: league.scoring.primaryChampionshipType === 'team'
|
||||||
|
? 'Team championship'
|
||||||
|
: league.scoring.primaryChampionshipType === 'nations'
|
||||||
|
? 'Nations championship'
|
||||||
|
: 'Trophy championship'}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5">
|
||||||
|
{league.scoring.scoringPatternSummary}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">Scoring: Not configured</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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) {
|
export default function Card({ children, className = '', onClick }: CardProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg bg-iron-gray p-6 shadow-card border border-charcoal-outline hover:shadow-glow transition-shadow duration-200 ${className}`}
|
className={`rounded-lg bg-iron-gray p-6 shadow-card border border-charcoal-outline duration-200 ${className}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
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 { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||||
import { Result } from '@gridpilot/racing/domain/entities/Result';
|
import { Result } from '@gridpilot/racing/domain/entities/Result';
|
||||||
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
|
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
|
||||||
|
import { Game } from '@gridpilot/racing/domain/entities/Game';
|
||||||
|
import { Season } from '@gridpilot/racing/domain/entities/Season';
|
||||||
|
|
||||||
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
|
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
|
||||||
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
||||||
@@ -17,6 +19,9 @@ import type { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRac
|
|||||||
import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository';
|
import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository';
|
||||||
import type { IStandingRepository } from '@gridpilot/racing/domain/repositories/IStandingRepository';
|
import type { IStandingRepository } from '@gridpilot/racing/domain/repositories/IStandingRepository';
|
||||||
import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository';
|
import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository';
|
||||||
|
import type { IGameRepository } from '@gridpilot/racing/domain/repositories/IGameRepository';
|
||||||
|
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
|
||||||
|
import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository';
|
||||||
import type {
|
import type {
|
||||||
ITeamRepository,
|
ITeamRepository,
|
||||||
ITeamMembershipRepository,
|
ITeamMembershipRepository,
|
||||||
@@ -34,6 +39,13 @@ import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/reposit
|
|||||||
import { InMemoryResultRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryResultRepository';
|
import { InMemoryResultRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryResultRepository';
|
||||||
import { InMemoryStandingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryStandingRepository';
|
import { InMemoryStandingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryStandingRepository';
|
||||||
import { InMemoryPenaltyRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryPenaltyRepository';
|
import { InMemoryPenaltyRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryPenaltyRepository';
|
||||||
|
import {
|
||||||
|
InMemoryGameRepository,
|
||||||
|
InMemorySeasonRepository,
|
||||||
|
InMemoryLeagueScoringConfigRepository,
|
||||||
|
getLeagueScoringPresetById,
|
||||||
|
} from '@gridpilot/racing/infrastructure/repositories/InMemoryScoringRepositories';
|
||||||
|
import { InMemoryLeagueScoringPresetProvider } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueScoringPresetProvider';
|
||||||
import { InMemoryTeamRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamRepository';
|
import { InMemoryTeamRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamRepository';
|
||||||
import { InMemoryTeamMembershipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamMembershipRepository';
|
import { InMemoryTeamMembershipRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryTeamMembershipRepository';
|
||||||
import { InMemoryRaceRegistrationRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRegistrationRepository';
|
import { InMemoryRaceRegistrationRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRegistrationRepository';
|
||||||
@@ -58,13 +70,28 @@ import {
|
|||||||
GetLeagueStandingsQuery,
|
GetLeagueStandingsQuery,
|
||||||
GetLeagueDriverSeasonStatsQuery,
|
GetLeagueDriverSeasonStatsQuery,
|
||||||
GetAllLeaguesWithCapacityQuery,
|
GetAllLeaguesWithCapacityQuery,
|
||||||
|
GetAllLeaguesWithCapacityAndScoringQuery,
|
||||||
|
ListLeagueScoringPresetsQuery,
|
||||||
|
GetLeagueScoringConfigQuery,
|
||||||
|
CreateLeagueWithSeasonAndScoringUseCase,
|
||||||
|
GetLeagueFullConfigQuery,
|
||||||
} from '@gridpilot/racing/application';
|
} from '@gridpilot/racing/application';
|
||||||
import { createStaticRacingSeed, type RacingSeedData } from '@gridpilot/testing-support';
|
import {
|
||||||
|
createStaticRacingSeed,
|
||||||
|
type RacingSeedData,
|
||||||
|
getDemoLeagueArchetypeByName,
|
||||||
|
} from '@gridpilot/testing-support';
|
||||||
|
import type {
|
||||||
|
LeagueScheduleDTO,
|
||||||
|
LeagueSchedulePreviewDTO,
|
||||||
|
} from '@gridpilot/racing/application';
|
||||||
|
import { PreviewLeagueScheduleQuery } from '@gridpilot/racing/application';
|
||||||
import {
|
import {
|
||||||
InMemoryFeedRepository,
|
InMemoryFeedRepository,
|
||||||
InMemorySocialGraphRepository,
|
InMemorySocialGraphRepository,
|
||||||
} from '@gridpilot/social/infrastructure/inmemory/InMemorySocialAndFeed';
|
} from '@gridpilot/social/infrastructure/inmemory/InMemorySocialAndFeed';
|
||||||
import { DemoImageServiceAdapter } from '@gridpilot/demo-infrastructure';
|
import { DemoImageServiceAdapter } from '@gridpilot/demo-infrastructure';
|
||||||
|
import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed data for development
|
* Seed data for development
|
||||||
@@ -138,6 +165,10 @@ class DIContainer {
|
|||||||
private _teamMembershipRepository: ITeamMembershipRepository;
|
private _teamMembershipRepository: ITeamMembershipRepository;
|
||||||
private _raceRegistrationRepository: IRaceRegistrationRepository;
|
private _raceRegistrationRepository: IRaceRegistrationRepository;
|
||||||
private _leagueMembershipRepository: ILeagueMembershipRepository;
|
private _leagueMembershipRepository: ILeagueMembershipRepository;
|
||||||
|
private _gameRepository: IGameRepository;
|
||||||
|
private _seasonRepository: ISeasonRepository;
|
||||||
|
private _leagueScoringConfigRepository: ILeagueScoringConfigRepository;
|
||||||
|
private _leagueScoringPresetProvider: LeagueScoringPresetProvider;
|
||||||
private _feedRepository: IFeedRepository;
|
private _feedRepository: IFeedRepository;
|
||||||
private _socialRepository: ISocialGraphRepository;
|
private _socialRepository: ISocialGraphRepository;
|
||||||
private _imageService: ImageServicePort;
|
private _imageService: ImageServicePort;
|
||||||
@@ -151,6 +182,13 @@ class DIContainer {
|
|||||||
private _getLeagueStandingsQuery: GetLeagueStandingsQuery;
|
private _getLeagueStandingsQuery: GetLeagueStandingsQuery;
|
||||||
private _getLeagueDriverSeasonStatsQuery: GetLeagueDriverSeasonStatsQuery;
|
private _getLeagueDriverSeasonStatsQuery: GetLeagueDriverSeasonStatsQuery;
|
||||||
private _getAllLeaguesWithCapacityQuery: GetAllLeaguesWithCapacityQuery;
|
private _getAllLeaguesWithCapacityQuery: GetAllLeaguesWithCapacityQuery;
|
||||||
|
private _getAllLeaguesWithCapacityAndScoringQuery: GetAllLeaguesWithCapacityAndScoringQuery;
|
||||||
|
private _listLeagueScoringPresetsQuery: ListLeagueScoringPresetsQuery;
|
||||||
|
private _getLeagueScoringConfigQuery: GetLeagueScoringConfigQuery;
|
||||||
|
private _createLeagueWithSeasonAndScoringUseCase: CreateLeagueWithSeasonAndScoringUseCase;
|
||||||
|
private _getLeagueFullConfigQuery: GetLeagueFullConfigQuery;
|
||||||
|
// Placeholder for future schedule preview wiring
|
||||||
|
private _previewLeagueScheduleQuery: PreviewLeagueScheduleQuery;
|
||||||
|
|
||||||
private _createTeamUseCase: CreateTeamUseCase;
|
private _createTeamUseCase: CreateTeamUseCase;
|
||||||
private _joinTeamUseCase: JoinTeamUseCase;
|
private _joinTeamUseCase: JoinTeamUseCase;
|
||||||
@@ -190,10 +228,51 @@ class DIContainer {
|
|||||||
|
|
||||||
// Race registrations (start empty; populated via use-cases)
|
// Race registrations (start empty; populated via use-cases)
|
||||||
this._raceRegistrationRepository = new InMemoryRaceRegistrationRepository();
|
this._raceRegistrationRepository = new InMemoryRaceRegistrationRepository();
|
||||||
|
|
||||||
// Penalties (seeded in-memory adapter)
|
// Penalties (seeded in-memory adapter)
|
||||||
this._penaltyRepository = new InMemoryPenaltyRepository();
|
this._penaltyRepository = new InMemoryPenaltyRepository();
|
||||||
|
|
||||||
|
// Scoring preset provider and seeded game/season/scoring config repositories
|
||||||
|
this._leagueScoringPresetProvider = new InMemoryLeagueScoringPresetProvider();
|
||||||
|
|
||||||
|
const game = Game.create({ id: 'iracing', name: 'iRacing' });
|
||||||
|
|
||||||
|
const seededSeasons: Season[] = [];
|
||||||
|
const seededScoringConfigs = [];
|
||||||
|
|
||||||
|
for (const league of seedData.leagues) {
|
||||||
|
const archetype = getDemoLeagueArchetypeByName(league.name);
|
||||||
|
if (!archetype) continue;
|
||||||
|
|
||||||
|
const season = Season.create({
|
||||||
|
id: `season-${league.id}-demo`,
|
||||||
|
leagueId: league.id,
|
||||||
|
gameId: game.id,
|
||||||
|
name: `${league.name} Demo Season`,
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
order: 1,
|
||||||
|
status: 'active',
|
||||||
|
startDate: new Date(),
|
||||||
|
endDate: new Date(),
|
||||||
|
});
|
||||||
|
seededSeasons.push(season);
|
||||||
|
|
||||||
|
const infraPreset = getLeagueScoringPresetById(
|
||||||
|
archetype.scoringPresetId,
|
||||||
|
);
|
||||||
|
if (!infraPreset) {
|
||||||
|
// If a preset is missing, skip scoring config for this league in alpha seed.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const config = infraPreset.createConfig({ seasonId: season.id });
|
||||||
|
seededScoringConfigs.push(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._gameRepository = new InMemoryGameRepository([game]);
|
||||||
|
this._seasonRepository = new InMemorySeasonRepository(seededSeasons);
|
||||||
|
this._leagueScoringConfigRepository =
|
||||||
|
new InMemoryLeagueScoringConfigRepository(seededScoringConfigs);
|
||||||
|
|
||||||
// League memberships seeded from static memberships with guaranteed owner roles
|
// League memberships seeded from static memberships with guaranteed owner roles
|
||||||
const seededMemberships: LeagueMembership[] = seedData.memberships.map((m) => ({
|
const seededMemberships: LeagueMembership[] = seedData.memberships.map((m) => ({
|
||||||
leagueId: m.leagueId,
|
leagueId: m.leagueId,
|
||||||
@@ -371,6 +450,46 @@ class DIContainer {
|
|||||||
this._leagueMembershipRepository,
|
this._leagueMembershipRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this._getAllLeaguesWithCapacityAndScoringQuery =
|
||||||
|
new GetAllLeaguesWithCapacityAndScoringQuery(
|
||||||
|
this._leagueRepository,
|
||||||
|
this._leagueMembershipRepository,
|
||||||
|
this._seasonRepository,
|
||||||
|
this._leagueScoringConfigRepository,
|
||||||
|
this._gameRepository,
|
||||||
|
this._leagueScoringPresetProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
this._listLeagueScoringPresetsQuery = new ListLeagueScoringPresetsQuery(
|
||||||
|
this._leagueScoringPresetProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
this._getLeagueScoringConfigQuery = new GetLeagueScoringConfigQuery(
|
||||||
|
this._leagueRepository,
|
||||||
|
this._seasonRepository,
|
||||||
|
this._leagueScoringConfigRepository,
|
||||||
|
this._gameRepository,
|
||||||
|
this._leagueScoringPresetProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
this._getLeagueFullConfigQuery = new GetLeagueFullConfigQuery(
|
||||||
|
this._leagueRepository,
|
||||||
|
this._seasonRepository,
|
||||||
|
this._leagueScoringConfigRepository,
|
||||||
|
this._gameRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
this._createLeagueWithSeasonAndScoringUseCase =
|
||||||
|
new CreateLeagueWithSeasonAndScoringUseCase(
|
||||||
|
this._leagueRepository,
|
||||||
|
this._seasonRepository,
|
||||||
|
this._leagueScoringConfigRepository,
|
||||||
|
this._leagueScoringPresetProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schedule preview query (used by league creation wizard step 3)
|
||||||
|
this._previewLeagueScheduleQuery = new PreviewLeagueScheduleQuery();
|
||||||
|
|
||||||
this._createTeamUseCase = new CreateTeamUseCase(
|
this._createTeamUseCase = new CreateTeamUseCase(
|
||||||
this._teamRepository,
|
this._teamRepository,
|
||||||
this._teamMembershipRepository,
|
this._teamMembershipRepository,
|
||||||
@@ -464,6 +583,22 @@ class DIContainer {
|
|||||||
return this._leagueMembershipRepository;
|
return this._leagueMembershipRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get gameRepository(): IGameRepository {
|
||||||
|
return this._gameRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
get seasonRepository(): ISeasonRepository {
|
||||||
|
return this._seasonRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
get leagueScoringConfigRepository(): ILeagueScoringConfigRepository {
|
||||||
|
return this._leagueScoringConfigRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
get leagueScoringPresetProvider(): LeagueScoringPresetProvider {
|
||||||
|
return this._leagueScoringPresetProvider;
|
||||||
|
}
|
||||||
|
|
||||||
get joinLeagueUseCase(): JoinLeagueUseCase {
|
get joinLeagueUseCase(): JoinLeagueUseCase {
|
||||||
return this._joinLeagueUseCase;
|
return this._joinLeagueUseCase;
|
||||||
}
|
}
|
||||||
@@ -496,6 +631,31 @@ class DIContainer {
|
|||||||
return this._getAllLeaguesWithCapacityQuery;
|
return this._getAllLeaguesWithCapacityQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get getAllLeaguesWithCapacityAndScoringQuery(): GetAllLeaguesWithCapacityAndScoringQuery {
|
||||||
|
return this._getAllLeaguesWithCapacityAndScoringQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
get listLeagueScoringPresetsQuery(): ListLeagueScoringPresetsQuery {
|
||||||
|
return this._listLeagueScoringPresetsQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
get getLeagueScoringConfigQuery(): GetLeagueScoringConfigQuery {
|
||||||
|
return this._getLeagueScoringConfigQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
get getLeagueFullConfigQuery(): GetLeagueFullConfigQuery {
|
||||||
|
return this._getLeagueFullConfigQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder accessor for schedule preview; API route/UI can call this later.
|
||||||
|
get previewLeagueScheduleQuery(): PreviewLeagueScheduleQuery {
|
||||||
|
return this._previewLeagueScheduleQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
get createLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase {
|
||||||
|
return this._createLeagueWithSeasonAndScoringUseCase;
|
||||||
|
}
|
||||||
|
|
||||||
get createTeamUseCase(): CreateTeamUseCase {
|
get createTeamUseCase(): CreateTeamUseCase {
|
||||||
return this._createTeamUseCase;
|
return this._createTeamUseCase;
|
||||||
}
|
}
|
||||||
@@ -628,6 +788,31 @@ export function getGetAllLeaguesWithCapacityQuery(): GetAllLeaguesWithCapacityQu
|
|||||||
return DIContainer.getInstance().getAllLeaguesWithCapacityQuery;
|
return DIContainer.getInstance().getAllLeaguesWithCapacityQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGetAllLeaguesWithCapacityAndScoringQuery(): GetAllLeaguesWithCapacityAndScoringQuery {
|
||||||
|
return DIContainer.getInstance().getAllLeaguesWithCapacityAndScoringQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGetLeagueScoringConfigQuery(): GetLeagueScoringConfigQuery {
|
||||||
|
return DIContainer.getInstance().getLeagueScoringConfigQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGetLeagueFullConfigQuery(): GetLeagueFullConfigQuery {
|
||||||
|
return DIContainer.getInstance().getLeagueFullConfigQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder export for future schedule preview API wiring.
|
||||||
|
export function getPreviewLeagueScheduleQuery(): PreviewLeagueScheduleQuery {
|
||||||
|
return DIContainer.getInstance().previewLeagueScheduleQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getListLeagueScoringPresetsQuery(): ListLeagueScoringPresetsQuery {
|
||||||
|
return DIContainer.getInstance().listLeagueScoringPresetsQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCreateLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase {
|
||||||
|
return DIContainer.getInstance().createLeagueWithSeasonAndScoringUseCase;
|
||||||
|
}
|
||||||
|
|
||||||
export function getTeamRepository(): ITeamRepository {
|
export function getTeamRepository(): ITeamRepository {
|
||||||
return DIContainer.getInstance().teamRepository;
|
return DIContainer.getInstance().teamRepository;
|
||||||
}
|
}
|
||||||
|
|||||||
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/GetLeagueStandingsQuery';
|
||||||
export * from './use-cases/GetLeagueDriverSeasonStatsQuery';
|
export * from './use-cases/GetLeagueDriverSeasonStatsQuery';
|
||||||
export * from './use-cases/GetAllLeaguesWithCapacityQuery';
|
export * from './use-cases/GetAllLeaguesWithCapacityQuery';
|
||||||
|
export * from './use-cases/GetAllLeaguesWithCapacityAndScoringQuery';
|
||||||
|
export * from './use-cases/ListLeagueScoringPresetsQuery';
|
||||||
|
export * from './use-cases/GetLeagueScoringConfigQuery';
|
||||||
export * from './use-cases/RecalculateChampionshipStandingsUseCase';
|
export * from './use-cases/RecalculateChampionshipStandingsUseCase';
|
||||||
|
export * from './use-cases/CreateLeagueWithSeasonAndScoringUseCase';
|
||||||
|
export * from './use-cases/GetLeagueFullConfigQuery';
|
||||||
|
export * from './use-cases/PreviewLeagueScheduleQuery';
|
||||||
|
|
||||||
// Re-export domain types for legacy callers (type-only)
|
// Re-export domain types for legacy callers (type-only)
|
||||||
export type {
|
export type {
|
||||||
LeagueMembership,
|
LeagueMembership,
|
||||||
MembershipRole,
|
MembershipRole,
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
JoinRequest,
|
JoinRequest,
|
||||||
} from '../domain/entities/LeagueMembership';
|
} from '../domain/entities/LeagueMembership';
|
||||||
|
|
||||||
export type { RaceRegistration } from '../domain/entities/RaceRegistration';
|
export type { RaceRegistration } from '../domain/entities/RaceRegistration';
|
||||||
|
|
||||||
@@ -43,7 +49,20 @@ export type { RaceDTO } from './dto/RaceDTO';
|
|||||||
export type { ResultDTO } from './dto/ResultDTO';
|
export type { ResultDTO } from './dto/ResultDTO';
|
||||||
export type { StandingDTO } from './dto/StandingDTO';
|
export type { StandingDTO } from './dto/StandingDTO';
|
||||||
export type { LeagueDriverSeasonStatsDTO } from './dto/LeagueDriverSeasonStatsDTO';
|
export type { LeagueDriverSeasonStatsDTO } from './dto/LeagueDriverSeasonStatsDTO';
|
||||||
|
export type {
|
||||||
|
LeagueScheduleDTO,
|
||||||
|
LeagueSchedulePreviewDTO,
|
||||||
|
} from './dto/LeagueScheduleDTO';
|
||||||
export type {
|
export type {
|
||||||
ChampionshipStandingsDTO,
|
ChampionshipStandingsDTO,
|
||||||
ChampionshipStandingsRowDTO,
|
ChampionshipStandingsRowDTO,
|
||||||
} from './dto/ChampionshipStandingsDTO';
|
} from './dto/ChampionshipStandingsDTO';
|
||||||
|
export type {
|
||||||
|
LeagueConfigFormModel,
|
||||||
|
LeagueStructureFormDTO,
|
||||||
|
LeagueChampionshipsFormDTO,
|
||||||
|
LeagueScoringFormDTO,
|
||||||
|
LeagueDropPolicyFormDTO,
|
||||||
|
LeagueStructureMode,
|
||||||
|
LeagueTimingsFormDTO,
|
||||||
|
} from './dto/LeagueConfigFormDTO';
|
||||||
@@ -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 {
|
export interface LeagueScoringConfig {
|
||||||
id: string;
|
id: string;
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
|
/**
|
||||||
|
* Optional ID of the scoring preset this configuration was derived from.
|
||||||
|
* Used by application-layer read models to surface preset metadata such as
|
||||||
|
* name and drop policy summaries.
|
||||||
|
*/
|
||||||
|
scoringPresetId?: string;
|
||||||
championships: ChampionshipConfig[];
|
championships: ChampionshipConfig[];
|
||||||
}
|
}
|
||||||
@@ -2,4 +2,5 @@ import type { LeagueScoringConfig } from '../entities/LeagueScoringConfig';
|
|||||||
|
|
||||||
export interface ILeagueScoringConfigRepository {
|
export interface ILeagueScoringConfigRepository {
|
||||||
findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null>;
|
findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null>;
|
||||||
|
save(config: LeagueScoringConfig): Promise<LeagueScoringConfig>;
|
||||||
}
|
}
|
||||||
@@ -3,4 +3,5 @@ import type { Season } from '../entities/Season';
|
|||||||
export interface ISeasonRepository {
|
export interface ISeasonRepository {
|
||||||
findById(id: string): Promise<Season | null>;
|
findById(id: string): Promise<Season | null>;
|
||||||
findByLeagueId(leagueId: string): Promise<Season[]>;
|
findByLeagueId(leagueId: string): Promise<Season[]>;
|
||||||
|
create(season: Season): Promise<Season>;
|
||||||
}
|
}
|
||||||
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 { ChampionshipType } from '@gridpilot/racing/domain/value-objects/ChampionshipType';
|
||||||
import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef';
|
import type { ParticipantRef } from '@gridpilot/racing/domain/value-objects/ParticipantRef';
|
||||||
|
|
||||||
|
export type LeagueScoringPresetPrimaryChampionshipType =
|
||||||
|
| 'driver'
|
||||||
|
| 'team'
|
||||||
|
| 'nations'
|
||||||
|
| 'trophy';
|
||||||
|
|
||||||
|
export interface LeagueScoringPreset {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
primaryChampionshipType: LeagueScoringPresetPrimaryChampionshipType;
|
||||||
|
dropPolicySummary: string;
|
||||||
|
sessionSummary: string;
|
||||||
|
bonusSummary: string;
|
||||||
|
createConfig: (options: { seasonId: string }) => LeagueScoringConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainPointsSprintMain = new PointsTable({
|
||||||
|
1: 25,
|
||||||
|
2: 18,
|
||||||
|
3: 15,
|
||||||
|
4: 12,
|
||||||
|
5: 10,
|
||||||
|
6: 8,
|
||||||
|
7: 6,
|
||||||
|
8: 4,
|
||||||
|
9: 2,
|
||||||
|
10: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sprintPointsSprintMain = new PointsTable({
|
||||||
|
1: 8,
|
||||||
|
2: 7,
|
||||||
|
3: 6,
|
||||||
|
4: 5,
|
||||||
|
5: 4,
|
||||||
|
6: 3,
|
||||||
|
7: 2,
|
||||||
|
8: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clubMainPoints = new PointsTable({
|
||||||
|
1: 20,
|
||||||
|
2: 15,
|
||||||
|
3: 12,
|
||||||
|
4: 10,
|
||||||
|
5: 8,
|
||||||
|
6: 6,
|
||||||
|
7: 4,
|
||||||
|
8: 2,
|
||||||
|
9: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enduranceMainPoints = new PointsTable({
|
||||||
|
1: 50,
|
||||||
|
2: 36,
|
||||||
|
3: 30,
|
||||||
|
4: 24,
|
||||||
|
5: 20,
|
||||||
|
6: 16,
|
||||||
|
7: 12,
|
||||||
|
8: 8,
|
||||||
|
9: 4,
|
||||||
|
10: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const leagueScoringPresets: LeagueScoringPreset[] = [
|
||||||
|
{
|
||||||
|
id: 'sprint-main-driver',
|
||||||
|
name: 'Sprint + Main',
|
||||||
|
description:
|
||||||
|
'Short sprint race plus main race; sprint gives fewer points.',
|
||||||
|
primaryChampionshipType: 'driver',
|
||||||
|
dropPolicySummary: 'Best 6 results of 8 count towards the championship.',
|
||||||
|
sessionSummary: 'Sprint + Main',
|
||||||
|
bonusSummary: 'Fastest lap +1 point in main race if finishing P10 or better.',
|
||||||
|
createConfig: ({ seasonId }) => {
|
||||||
|
const fastestLapBonus: BonusRule = {
|
||||||
|
id: 'fastest-lap-main',
|
||||||
|
type: 'fastestLap',
|
||||||
|
points: 1,
|
||||||
|
requiresFinishInTopN: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionTypes: SessionType[] = ['sprint', 'main'];
|
||||||
|
|
||||||
|
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
|
||||||
|
sprint: sprintPointsSprintMain,
|
||||||
|
main: mainPointsSprintMain,
|
||||||
|
practice: new PointsTable({}),
|
||||||
|
qualifying: new PointsTable({}),
|
||||||
|
q1: new PointsTable({}),
|
||||||
|
q2: new PointsTable({}),
|
||||||
|
q3: new PointsTable({}),
|
||||||
|
timeTrial: new PointsTable({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const bonusRulesBySessionType: Record<SessionType, BonusRule[]> = {
|
||||||
|
sprint: [],
|
||||||
|
main: [fastestLapBonus],
|
||||||
|
practice: [],
|
||||||
|
qualifying: [],
|
||||||
|
q1: [],
|
||||||
|
q2: [],
|
||||||
|
q3: [],
|
||||||
|
timeTrial: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropScorePolicy: DropScorePolicy = {
|
||||||
|
strategy: 'bestNResults',
|
||||||
|
count: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const championship: ChampionshipConfig = {
|
||||||
|
id: 'driver-champ-sprint-main',
|
||||||
|
name: 'Driver Championship',
|
||||||
|
type: 'driver' as ChampionshipType,
|
||||||
|
sessionTypes,
|
||||||
|
pointsTableBySessionType,
|
||||||
|
bonusRulesBySessionType,
|
||||||
|
dropScorePolicy,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `lsc-${seasonId}-sprint-main-driver`,
|
||||||
|
seasonId,
|
||||||
|
scoringPresetId: 'sprint-main-driver',
|
||||||
|
championships: [championship],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'club-default',
|
||||||
|
name: 'Club ladder',
|
||||||
|
description:
|
||||||
|
'Simple club ladder with a single main race and no bonuses or drop scores.',
|
||||||
|
primaryChampionshipType: 'driver',
|
||||||
|
dropPolicySummary: 'All race results count, no drop scores.',
|
||||||
|
sessionSummary: 'Main race only',
|
||||||
|
bonusSummary: 'No bonus points.',
|
||||||
|
createConfig: ({ seasonId }) => {
|
||||||
|
const sessionTypes: SessionType[] = ['main'];
|
||||||
|
|
||||||
|
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
|
||||||
|
sprint: new PointsTable({}),
|
||||||
|
main: clubMainPoints,
|
||||||
|
practice: new PointsTable({}),
|
||||||
|
qualifying: new PointsTable({}),
|
||||||
|
q1: new PointsTable({}),
|
||||||
|
q2: new PointsTable({}),
|
||||||
|
q3: new PointsTable({}),
|
||||||
|
timeTrial: new PointsTable({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropScorePolicy: DropScorePolicy = {
|
||||||
|
strategy: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const championship: ChampionshipConfig = {
|
||||||
|
id: 'driver-champ-club-default',
|
||||||
|
name: 'Driver Championship',
|
||||||
|
type: 'driver' as ChampionshipType,
|
||||||
|
sessionTypes,
|
||||||
|
pointsTableBySessionType,
|
||||||
|
dropScorePolicy,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `lsc-${seasonId}-club-default`,
|
||||||
|
seasonId,
|
||||||
|
scoringPresetId: 'club-default',
|
||||||
|
championships: [championship],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'endurance-main-double',
|
||||||
|
name: 'Endurance weekend',
|
||||||
|
description:
|
||||||
|
'Single main endurance race with double points and a simple drop policy.',
|
||||||
|
primaryChampionshipType: 'driver',
|
||||||
|
dropPolicySummary: 'Best 4 results of 6 count towards the championship.',
|
||||||
|
sessionSummary: 'Main race only',
|
||||||
|
bonusSummary: 'No bonus points.',
|
||||||
|
createConfig: ({ seasonId }) => {
|
||||||
|
const sessionTypes: SessionType[] = ['main'];
|
||||||
|
|
||||||
|
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
|
||||||
|
sprint: new PointsTable({}),
|
||||||
|
main: enduranceMainPoints,
|
||||||
|
practice: new PointsTable({}),
|
||||||
|
qualifying: new PointsTable({}),
|
||||||
|
q1: new PointsTable({}),
|
||||||
|
q2: new PointsTable({}),
|
||||||
|
q3: new PointsTable({}),
|
||||||
|
timeTrial: new PointsTable({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropScorePolicy: DropScorePolicy = {
|
||||||
|
strategy: 'bestNResults',
|
||||||
|
count: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const championship: ChampionshipConfig = {
|
||||||
|
id: 'driver-champ-endurance-main-double',
|
||||||
|
name: 'Driver Championship',
|
||||||
|
type: 'driver' as ChampionshipType,
|
||||||
|
sessionTypes,
|
||||||
|
pointsTableBySessionType,
|
||||||
|
dropScorePolicy,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `lsc-${seasonId}-endurance-main-double`,
|
||||||
|
seasonId,
|
||||||
|
scoringPresetId: 'endurance-main-double',
|
||||||
|
championships: [championship],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function listLeagueScoringPresets(): LeagueScoringPreset[] {
|
||||||
|
return [...leagueScoringPresets];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLeagueScoringPresetById(
|
||||||
|
id: string,
|
||||||
|
): LeagueScoringPreset | undefined {
|
||||||
|
return leagueScoringPresets.find((preset) => preset.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
export class InMemoryGameRepository implements IGameRepository {
|
export class InMemoryGameRepository implements IGameRepository {
|
||||||
private games: Game[];
|
private games: Game[];
|
||||||
|
|
||||||
@@ -49,6 +281,11 @@ export class InMemorySeasonRepository implements ISeasonRepository {
|
|||||||
return this.seasons.filter((s) => s.leagueId === leagueId);
|
return this.seasons.filter((s) => s.leagueId === leagueId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(season: Season): Promise<Season> {
|
||||||
|
this.seasons.push(season);
|
||||||
|
return season;
|
||||||
|
}
|
||||||
|
|
||||||
seed(season: Season): void {
|
seed(season: Season): void {
|
||||||
this.seasons.push(season);
|
this.seasons.push(season);
|
||||||
}
|
}
|
||||||
@@ -67,6 +304,18 @@ export class InMemoryLeagueScoringConfigRepository
|
|||||||
return this.configs.find((c) => c.seasonId === seasonId) ?? null;
|
return this.configs.find((c) => c.seasonId === seasonId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async save(config: LeagueScoringConfig): Promise<LeagueScoringConfig> {
|
||||||
|
const existingIndex = this.configs.findIndex(
|
||||||
|
(c) => c.id === config.id,
|
||||||
|
);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
this.configs[existingIndex] = config;
|
||||||
|
} else {
|
||||||
|
this.configs.push(config);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
seed(config: LeagueScoringConfig): void {
|
seed(config: LeagueScoringConfig): void {
|
||||||
this.configs.push(config);
|
this.configs.push(config);
|
||||||
}
|
}
|
||||||
@@ -99,7 +348,7 @@ export class InMemoryChampionshipStandingRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createF1DemoScoringSetup(params: {
|
export function createSprintMainDemoScoringSetup(params: {
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
seasonId?: string;
|
seasonId?: string;
|
||||||
}): {
|
}): {
|
||||||
@@ -111,7 +360,7 @@ export function createF1DemoScoringSetup(params: {
|
|||||||
championshipId: string;
|
championshipId: string;
|
||||||
} {
|
} {
|
||||||
const { leagueId } = params;
|
const { leagueId } = params;
|
||||||
const seasonId = params.seasonId ?? 'season-f1-demo';
|
const seasonId = params.seasonId ?? 'season-sprint-main-demo';
|
||||||
const championshipId = 'driver-champ';
|
const championshipId = 'driver-champ';
|
||||||
|
|
||||||
const game = Game.create({ id: 'iracing', name: 'iRacing' });
|
const game = Game.create({ id: 'iracing', name: 'iRacing' });
|
||||||
@@ -120,7 +369,7 @@ export function createF1DemoScoringSetup(params: {
|
|||||||
id: seasonId,
|
id: seasonId,
|
||||||
leagueId,
|
leagueId,
|
||||||
gameId: game.id,
|
gameId: game.id,
|
||||||
name: 'F1-Style Demo Season',
|
name: 'Sprint + Main Demo Season',
|
||||||
year: 2025,
|
year: 2025,
|
||||||
order: 1,
|
order: 1,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -128,81 +377,14 @@ export function createF1DemoScoringSetup(params: {
|
|||||||
endDate: new Date('2025-12-31'),
|
endDate: new Date('2025-12-31'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mainPoints = new PointsTable({
|
const preset = getLeagueScoringPresetById('sprint-main-driver');
|
||||||
1: 25,
|
if (!preset) {
|
||||||
2: 18,
|
throw new Error('Missing sprint-main-driver scoring preset');
|
||||||
3: 15,
|
}
|
||||||
4: 12,
|
|
||||||
5: 10,
|
|
||||||
6: 8,
|
|
||||||
7: 6,
|
|
||||||
8: 4,
|
|
||||||
9: 2,
|
|
||||||
10: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sprintPoints = new PointsTable({
|
const leagueScoringConfig: LeagueScoringConfig = preset.createConfig({
|
||||||
1: 8,
|
|
||||||
2: 7,
|
|
||||||
3: 6,
|
|
||||||
4: 5,
|
|
||||||
5: 4,
|
|
||||||
6: 3,
|
|
||||||
7: 2,
|
|
||||||
8: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fastestLapBonus: BonusRule = {
|
|
||||||
id: 'fastest-lap-main',
|
|
||||||
type: 'fastestLap',
|
|
||||||
points: 1,
|
|
||||||
requiresFinishInTopN: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sessionTypes: SessionType[] = ['sprint', 'main'];
|
|
||||||
|
|
||||||
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
|
|
||||||
sprint: sprintPoints,
|
|
||||||
main: mainPoints,
|
|
||||||
practice: new PointsTable({}),
|
|
||||||
qualifying: new PointsTable({}),
|
|
||||||
q1: new PointsTable({}),
|
|
||||||
q2: new PointsTable({}),
|
|
||||||
q3: new PointsTable({}),
|
|
||||||
timeTrial: new PointsTable({}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const bonusRulesBySessionType: Record<SessionType, BonusRule[]> = {
|
|
||||||
sprint: [],
|
|
||||||
main: [fastestLapBonus],
|
|
||||||
practice: [],
|
|
||||||
qualifying: [],
|
|
||||||
q1: [],
|
|
||||||
q2: [],
|
|
||||||
q3: [],
|
|
||||||
timeTrial: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropScorePolicy: DropScorePolicy = {
|
|
||||||
strategy: 'bestNResults',
|
|
||||||
count: 6,
|
|
||||||
};
|
|
||||||
|
|
||||||
const championship: ChampionshipConfig = {
|
|
||||||
id: championshipId,
|
|
||||||
name: 'Driver Championship',
|
|
||||||
type: 'driver' as ChampionshipType,
|
|
||||||
sessionTypes,
|
|
||||||
pointsTableBySessionType,
|
|
||||||
bonusRulesBySessionType,
|
|
||||||
dropScorePolicy,
|
|
||||||
};
|
|
||||||
|
|
||||||
const leagueScoringConfig: LeagueScoringConfig = {
|
|
||||||
id: 'lsc-f1-demo',
|
|
||||||
seasonId: season.id,
|
seasonId: season.id,
|
||||||
championships: [championship],
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const gameRepo = new InMemoryGameRepository([game]);
|
const gameRepo = new InMemoryGameRepository([game]);
|
||||||
const seasonRepo = new InMemorySeasonRepository([season]);
|
const seasonRepo = new InMemorySeasonRepository([season]);
|
||||||
|
|||||||
@@ -85,9 +85,9 @@ function createDrivers(count: number): Driver[] {
|
|||||||
|
|
||||||
function createLeagues(ownerIds: string[]): League[] {
|
function createLeagues(ownerIds: string[]): League[] {
|
||||||
const leagueNames = [
|
const leagueNames = [
|
||||||
'Global GT Masters',
|
'GridPilot Sprint Series',
|
||||||
'Midnight Endurance Series',
|
'GridPilot Endurance Cup',
|
||||||
'Virtual Touring Cup',
|
'GridPilot Club Ladder',
|
||||||
'Sprint Challenge League',
|
'Sprint Challenge League',
|
||||||
'Club Racers Collective',
|
'Club Racers Collective',
|
||||||
'Sim Racing Alliance',
|
'Sim Racing Alliance',
|
||||||
@@ -104,12 +104,29 @@ function createLeagues(ownerIds: string[]): League[] {
|
|||||||
const ownerId = pickOne(ownerIds);
|
const ownerId = pickOne(ownerIds);
|
||||||
|
|
||||||
const maxDriversOptions = [24, 32, 48, 64];
|
const maxDriversOptions = [24, 32, 48, 64];
|
||||||
const settings = {
|
let settings = {
|
||||||
pointsSystem: faker.helpers.arrayElement(['f1-2024', 'indycar']),
|
pointsSystem: faker.helpers.arrayElement(['f1-2024', 'indycar']),
|
||||||
sessionDuration: faker.helpers.arrayElement([45, 60, 90, 120]),
|
sessionDuration: faker.helpers.arrayElement([45, 60, 90, 120]),
|
||||||
qualifyingFormat: faker.helpers.arrayElement(['open', 'single-lap']),
|
qualifyingFormat: faker.helpers.arrayElement(['open', 'single-lap']),
|
||||||
maxDrivers: faker.helpers.arrayElement(maxDriversOptions),
|
maxDrivers: faker.helpers.arrayElement(maxDriversOptions),
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
settings = {
|
||||||
|
...settings,
|
||||||
|
maxDrivers: 24,
|
||||||
|
};
|
||||||
|
} else if (i === 1) {
|
||||||
|
settings = {
|
||||||
|
...settings,
|
||||||
|
maxDrivers: 24,
|
||||||
|
};
|
||||||
|
} else if (i === 2) {
|
||||||
|
settings = {
|
||||||
|
...settings,
|
||||||
|
maxDrivers: 40,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const socialLinks =
|
const socialLinks =
|
||||||
i === 0
|
i === 0
|
||||||
@@ -615,4 +632,60 @@ export function getLatestResults(limit?: number): readonly RaceWithResultsDTO[]
|
|||||||
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime());
|
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime());
|
||||||
|
|
||||||
return typeof limit === 'number' ? sorted.slice(0, limit) : sorted;
|
return typeof limit === 'number' ? sorted.slice(0, limit) : sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo league archetype helper for seeding structure and scoring.
|
||||||
|
*
|
||||||
|
* This keeps archetype knowledge local to the static racing seed while allowing
|
||||||
|
* demo infrastructure (e.g. DI container) to attach seasons and scoring configs.
|
||||||
|
*/
|
||||||
|
export type DemoLeagueArchetype =
|
||||||
|
| {
|
||||||
|
id: 'sprint-series';
|
||||||
|
name: 'GridPilot Sprint Series';
|
||||||
|
structure: { mode: 'solo'; maxDrivers: 24 };
|
||||||
|
scoringPresetId: 'sprint-main-driver';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: 'endurance-cup';
|
||||||
|
name: 'GridPilot Endurance Cup';
|
||||||
|
structure: { mode: 'fixedTeams'; maxTeams: 12; driversPerTeam: 2 };
|
||||||
|
scoringPresetId: 'endurance-main-double';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: 'club-ladder';
|
||||||
|
name: 'GridPilot Club Ladder';
|
||||||
|
structure: { mode: 'solo'; maxDrivers: 40 };
|
||||||
|
scoringPresetId: 'club-default';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDemoLeagueArchetypeByName(
|
||||||
|
leagueName: string,
|
||||||
|
): DemoLeagueArchetype | undefined {
|
||||||
|
switch (leagueName) {
|
||||||
|
case 'GridPilot Sprint Series':
|
||||||
|
return {
|
||||||
|
id: 'sprint-series',
|
||||||
|
name: 'GridPilot Sprint Series',
|
||||||
|
structure: { mode: 'solo', maxDrivers: 24 },
|
||||||
|
scoringPresetId: 'sprint-main-driver',
|
||||||
|
};
|
||||||
|
case 'GridPilot Endurance Cup':
|
||||||
|
return {
|
||||||
|
id: 'endurance-cup',
|
||||||
|
name: 'GridPilot Endurance Cup',
|
||||||
|
structure: { mode: 'fixedTeams', maxTeams: 12, driversPerTeam: 2 },
|
||||||
|
scoringPresetId: 'endurance-main-double',
|
||||||
|
};
|
||||||
|
case 'GridPilot Club Ladder':
|
||||||
|
return {
|
||||||
|
id: 'club-ladder',
|
||||||
|
name: 'GridPilot Club Ladder',
|
||||||
|
structure: { mode: 'solo', maxDrivers: 40 },
|
||||||
|
scoringPresetId: 'club-default',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user