wip
This commit is contained in:
94
apps/website/app/api/leagues/schedule-preview/route.ts
Normal file
94
apps/website/app/api/leagues/schedule-preview/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import {
|
||||
type LeagueScheduleDTO,
|
||||
type LeagueSchedulePreviewDTO,
|
||||
} from '@gridpilot/racing/application';
|
||||
import { getPreviewLeagueScheduleQuery } from '@/lib/di-container';
|
||||
|
||||
interface RequestBody {
|
||||
seasonStartDate?: string;
|
||||
raceStartTime?: string;
|
||||
timezoneId?: string;
|
||||
recurrenceStrategy?: LeagueScheduleDTO['recurrenceStrategy'];
|
||||
intervalWeeks?: number;
|
||||
weekdays?: LeagueScheduleDTO['weekdays'];
|
||||
monthlyOrdinal?: LeagueScheduleDTO['monthlyOrdinal'];
|
||||
monthlyWeekday?: LeagueScheduleDTO['monthlyWeekday'];
|
||||
plannedRounds?: number;
|
||||
}
|
||||
|
||||
function toLeagueScheduleDTO(body: RequestBody): LeagueScheduleDTO {
|
||||
const {
|
||||
seasonStartDate,
|
||||
raceStartTime,
|
||||
timezoneId,
|
||||
recurrenceStrategy,
|
||||
intervalWeeks,
|
||||
weekdays,
|
||||
monthlyOrdinal,
|
||||
monthlyWeekday,
|
||||
plannedRounds,
|
||||
} = body;
|
||||
|
||||
if (
|
||||
!seasonStartDate ||
|
||||
!raceStartTime ||
|
||||
!timezoneId ||
|
||||
!recurrenceStrategy ||
|
||||
plannedRounds == null
|
||||
) {
|
||||
throw new Error(
|
||||
'seasonStartDate, raceStartTime, timezoneId, recurrenceStrategy, and plannedRounds are required',
|
||||
);
|
||||
}
|
||||
|
||||
const dto: LeagueScheduleDTO = {
|
||||
seasonStartDate,
|
||||
raceStartTime,
|
||||
timezoneId,
|
||||
recurrenceStrategy,
|
||||
plannedRounds,
|
||||
};
|
||||
|
||||
if (intervalWeeks != null) {
|
||||
dto.intervalWeeks = intervalWeeks;
|
||||
}
|
||||
if (weekdays && weekdays.length > 0) {
|
||||
dto.weekdays = weekdays;
|
||||
}
|
||||
if (monthlyOrdinal != null) {
|
||||
dto.monthlyOrdinal = monthlyOrdinal;
|
||||
}
|
||||
if (monthlyWeekday != null) {
|
||||
dto.monthlyWeekday = monthlyWeekday;
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const json = (await request.json()) as RequestBody;
|
||||
|
||||
const schedule = toLeagueScheduleDTO(json);
|
||||
|
||||
const query = getPreviewLeagueScheduleQuery();
|
||||
const preview: LeagueSchedulePreviewDTO = await query.execute({
|
||||
schedule,
|
||||
maxRounds: 10,
|
||||
});
|
||||
|
||||
return NextResponse.json(preview, { status: 200 });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to preview schedule';
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: message,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,21 @@ import LeagueMembers from '@/components/leagues/LeagueMembers';
|
||||
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
|
||||
import LeagueAdmin from '@/components/leagues/LeagueAdmin';
|
||||
import StandingsTable from '@/components/leagues/StandingsTable';
|
||||
import LeagueScoringTab from '@/components/leagues/LeagueScoringTab';
|
||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
||||
import { League } from '@gridpilot/racing/domain/entities/League';
|
||||
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO';
|
||||
import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
getLeagueRepository,
|
||||
getRaceRepository,
|
||||
getDriverRepository,
|
||||
getGetLeagueDriverSeasonStatsQuery,
|
||||
getGetLeagueScoringConfigQuery,
|
||||
getDriverStats,
|
||||
getAllDriverRankings,
|
||||
} from '@/lib/di-container';
|
||||
@@ -36,9 +39,12 @@ export default function LeagueDetailPage() {
|
||||
const [owner, setOwner] = useState<Driver | null>(null);
|
||||
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
|
||||
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
|
||||
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'schedule' | 'standings' | 'admin'>('overview');
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
'overview' | 'schedule' | 'standings' | 'scoring' | 'admin'
|
||||
>('overview');
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
@@ -71,6 +77,11 @@ export default function LeagueDetailPage() {
|
||||
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
|
||||
setStandings(leagueStandings);
|
||||
|
||||
// Load scoring configuration for the active season
|
||||
const getLeagueScoringConfigQuery = getGetLeagueScoringConfigQuery();
|
||||
const scoring = await getLeagueScoringConfigQuery.execute({ leagueId });
|
||||
setScoringConfig(scoring);
|
||||
|
||||
// Load all drivers for standings and map to DTOs for UI components
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const driverDtos: DriverDTO[] = allDrivers
|
||||
@@ -100,9 +111,10 @@ export default function LeagueDetailPage() {
|
||||
initialTab === 'overview' ||
|
||||
initialTab === 'schedule' ||
|
||||
initialTab === 'standings' ||
|
||||
initialTab === 'scoring' ||
|
||||
initialTab === 'admin'
|
||||
) {
|
||||
setActiveTab(initialTab);
|
||||
setActiveTab(initialTab as typeof activeTab);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
@@ -231,6 +243,16 @@ export default function LeagueDetailPage() {
|
||||
>
|
||||
Standings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('scoring')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'scoring'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Scoring
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setActiveTab('admin')}
|
||||
@@ -266,22 +288,36 @@ export default function LeagueDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-charcoal-outline">
|
||||
<h3 className="text-white font-medium mb-3">League Settings</h3>
|
||||
<h3 className="text-white font-medium mb-3">At a glance</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Points System</label>
|
||||
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||
Structure
|
||||
</h4>
|
||||
<p className="text-gray-200">
|
||||
Solo • {league.settings.maxDrivers ?? 32} drivers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Session Duration</label>
|
||||
<p className="text-white">{league.settings.sessionDuration} minutes</p>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||
Schedule
|
||||
</h4>
|
||||
<p className="text-gray-200">
|
||||
{`? rounds • 30 min Qualifying • ${
|
||||
typeof league.settings.sessionDuration === 'number'
|
||||
? league.settings.sessionDuration
|
||||
: 40
|
||||
} min Races`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Qualifying Format</label>
|
||||
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||
Scoring & drops
|
||||
</h4>
|
||||
<p className="text-gray-200">
|
||||
{league.settings.pointsSystem.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -439,6 +475,23 @@ export default function LeagueDetailPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'scoring' && (
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Scoring</h2>
|
||||
<LeagueScoringTab
|
||||
scoringConfig={scoringConfig}
|
||||
practiceMinutes={20}
|
||||
qualifyingMinutes={30}
|
||||
sprintRaceMinutes={20}
|
||||
mainRaceMinutes={
|
||||
typeof league.settings.sessionDuration === 'number'
|
||||
? league.settings.sessionDuration
|
||||
: 40
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'admin' && isAdmin && (
|
||||
<LeagueAdmin
|
||||
league={league}
|
||||
|
||||
15
apps/website/app/leagues/create/page.tsx
Normal file
15
apps/website/app/leagues/create/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import CreateLeagueWizard from '@/components/leagues/CreateLeagueWizard';
|
||||
import Section from '@/components/ui/Section';
|
||||
import Container from '@/components/ui/Container';
|
||||
|
||||
export default function CreateLeaguePage() {
|
||||
return (
|
||||
<Section>
|
||||
<Container size="md">
|
||||
<CreateLeagueWizard />
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -3,18 +3,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import LeagueCard from '@/components/leagues/LeagueCard';
|
||||
import CreateLeagueForm from '@/components/leagues/CreateLeagueForm';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Input from '@/components/ui/Input';
|
||||
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
|
||||
import { getGetAllLeaguesWithCapacityQuery } from '@/lib/di-container';
|
||||
import type { LeagueSummaryDTO } from '@gridpilot/racing/application/dto/LeagueSummaryDTO';
|
||||
import { getGetAllLeaguesWithCapacityAndScoringQuery } from '@/lib/di-container';
|
||||
|
||||
export default function LeaguesPage() {
|
||||
const router = useRouter();
|
||||
const [leagues, setLeagues] = useState<LeagueDTO[]>([]);
|
||||
const [leagues, setLeagues] = useState<LeagueSummaryDTO[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
|
||||
@@ -24,7 +22,7 @@ export default function LeaguesPage() {
|
||||
|
||||
const loadLeagues = async () => {
|
||||
try {
|
||||
const query = getGetAllLeaguesWithCapacityQuery();
|
||||
const query = getGetAllLeaguesWithCapacityAndScoringQuery();
|
||||
const allLeagues = await query.execute();
|
||||
setLeagues(allLeagues);
|
||||
} catch (error) {
|
||||
@@ -78,24 +76,12 @@ export default function LeaguesPage() {
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
onClick={() => router.push('/leagues/create')}
|
||||
>
|
||||
{showCreateForm ? 'Cancel' : 'Create League'}
|
||||
Create League
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreateForm && (
|
||||
<Card className="mb-8 max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Create New League</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Experiment with different point systems
|
||||
</p>
|
||||
</div>
|
||||
<CreateLeagueForm />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{leagues.length > 0 && (
|
||||
<Card className="mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
Reference in New Issue
Block a user