This commit is contained in:
2025-12-11 21:06:25 +01:00
parent c49ea2598d
commit ec3ddc3a5c
227 changed files with 3496 additions and 2083 deletions

View File

@@ -156,7 +156,8 @@ function getDefaultSeasonStartDate(): string {
const daysUntilSaturday = (6 - now.getDay() + 7) % 7 || 7;
const nextSaturday = new Date(now);
nextSaturday.setDate(now.getDate() + daysUntilSaturday);
return nextSaturday.toISOString().split('T')[0];
const [datePart] = nextSaturday.toISOString().split('T');
return datePart ?? '';
}
function createDefaultForm(): LeagueConfigFormModel {
@@ -172,8 +173,6 @@ function createDefaultForm(): LeagueConfigFormModel {
structure: {
mode: 'solo',
maxDrivers: 24,
maxTeams: undefined,
driversPerTeam: undefined,
multiClassEnabled: false,
},
championships: {
@@ -193,7 +192,7 @@ function createDefaultForm(): LeagueConfigFormModel {
timings: {
practiceMinutes: 20,
qualifyingMinutes: 30,
sprintRaceMinutes: defaultPatternId === 'sprint-main-driver' ? 20 : undefined,
sprintRaceMinutes: 20,
mainRaceMinutes: 40,
sessionCount: 2,
roundsPlanned: 8,
@@ -265,12 +264,13 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
const query = getListLeagueScoringPresetsQuery();
const result = await query.execute();
setPresets(result);
if (result.length > 0) {
const firstPreset = result[0];
if (firstPreset) {
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
patternId: prev.scoring.patternId || result[0].id,
patternId: prev.scoring.patternId || firstPreset.id,
customScoringEnabled: prev.scoring.customScoringEnabled ?? false,
},
}));
@@ -338,7 +338,10 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
}
setLoading(true);
setErrors((prev) => ({ ...prev, submit: undefined }));
setErrors((prev) => {
const { submit, ...rest } = prev;
return rest;
});
try {
const result = await createLeagueFromConfig(form);
@@ -577,7 +580,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics}
errors={errors.basics ?? {}}
/>
</div>
)}
@@ -587,7 +590,11 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
<LeagueVisibilitySection
form={form}
onChange={setForm}
errors={errors.basics}
errors={
errors.basics?.visibility
? { visibility: errors.basics.visibility }
: {}
}
/>
</div>
)}
@@ -607,7 +614,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
<LeagueTimingsSection
form={form}
onChange={setForm}
errors={errors.timings}
errors={errors.timings ?? {}}
/>
</div>
)}
@@ -619,7 +626,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
scoring={form.scoring}
presets={presets}
readOnly={presetsLoading}
patternError={errors.scoring?.patternId}
patternError={errors.scoring?.patternId ?? ''}
onChangePatternId={handleScoringPresetChange}
onToggleCustomScoring={() =>
setForm((prev) => ({

View File

@@ -32,24 +32,19 @@ export default function JoinLeagueButton({
const membershipRepo = getLeagueMembershipRepository();
if (isInviteOnly) {
// For alpha, treat "request to join" as creating a pending membership
const pending = await membershipRepo.getMembership(leagueId, currentDriverId);
if (pending) {
const existing = await membershipRepo.getMembership(leagueId, currentDriverId);
if (existing) {
throw new Error('Already a member or have a pending request');
}
await membershipRepo.saveMembership({
leagueId,
driverId: currentDriverId,
role: 'member',
status: 'pending',
joinedAt: new Date(),
});
} else {
const useCase = getJoinLeagueUseCase();
await useCase.execute({ leagueId, driverId: currentDriverId });
throw new Error(
'Requesting to join invite-only leagues is not available in this alpha build.',
);
}
const useCase = getJoinLeagueUseCase();
await useCase.execute({ leagueId, driverId: currentDriverId });
onMembershipChange?.();
setShowConfirmDialog(false);
} catch (err) {

View File

@@ -20,6 +20,7 @@ import {
type LeagueAdminProtestsViewModel,
} from '@/lib/presenters/LeagueAdminPresenter';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import type { LeagueSummaryViewModel } from '@/lib/presenters/LeagueAdminPresenter';
import { LeagueBasicsSection } from './LeagueBasicsSection';
import { LeagueStructureSection } from './LeagueStructureSection';
import { LeagueScoringSection } from './LeagueScoringSection';
@@ -37,13 +38,7 @@ import { AlertTriangle, CheckCircle, Clock, XCircle, Flag, Calendar, User, Dolla
type JoinRequest = LeagueJoinRequestViewModel;
interface LeagueAdminProps {
league: {
id: string;
ownerId: string;
settings: {
pointsSystem: string;
};
};
league: LeagueSummaryViewModel;
onLeagueUpdate?: () => void;
}
@@ -83,7 +78,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
useEffect(() => {
async function loadOwner() {
try {
const summary = await loadLeagueOwnerSummary(league);
const summary = await loadLeagueOwnerSummary({ ownerId: league.ownerId });
setOwnerSummary(summary);
} catch (err) {
console.error('Failed to load league owner:', err);

View File

@@ -3,9 +3,7 @@
import React from 'react';
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
import Input from '@/components/ui/Input';
import type {
LeagueConfigFormModel,
} from '@gridpilot/racing/application';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
interface LeagueBasicsSectionProps {
form: LeagueConfigFormModel;

View File

@@ -259,13 +259,19 @@ export function LeagueDropSection({
if (disabled || !onChange) return;
const option = DROP_OPTIONS.find((o) => o.value === strategy);
onChange({
const next: LeagueConfigFormModel = {
...form,
dropPolicy: {
strategy,
n: strategy === 'none' ? undefined : (dropPolicy.n ?? option?.defaultN),
},
});
dropPolicy:
strategy === 'none'
? {
strategy,
}
: {
strategy,
n: dropPolicy.n ?? option?.defaultN ?? 1,
},
};
onChange(next);
};
const handleNChange = (delta: number) => {

View File

@@ -136,7 +136,7 @@ export default function LeagueMembers({
<label className="text-sm text-gray-400">Sort by:</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="rating">Rating</option>

View File

@@ -357,20 +357,33 @@ export function LeagueScoringSection({
},
});
};
const patternPanel = (
<ScoringPatternSection
scoring={form.scoring}
presets={presets}
readOnly={readOnly}
onChangePatternId={!readOnly && onChange ? handleSelectPreset : undefined}
onToggleCustomScoring={disabled ? undefined : handleToggleCustomScoring}
/>
);
const championshipsPanel = (
<ChampionshipsSection form={form} onChange={onChange} readOnly={readOnly} />
);
const patternProps: ScoringPatternSectionProps = {
scoring: form.scoring,
presets,
readOnly: !!readOnly,
};
if (!readOnly && onChange) {
patternProps.onChangePatternId = handleSelectPreset;
}
if (!disabled) {
patternProps.onToggleCustomScoring = handleToggleCustomScoring;
}
const patternPanel = <ScoringPatternSection {...patternProps} />;
const championshipsProps: ChampionshipsSectionProps = {
form,
readOnly: !!readOnly,
};
if (onChange) {
championshipsProps.onChange = onChange;
}
const championshipsPanel = <ChampionshipsSection {...championshipsProps} />;
if (patternOnly) {
return <div>{patternPanel}</div>;

View File

@@ -11,6 +11,7 @@ import {
getRejectSponsorshipRequestUseCase,
getSeasonRepository,
} from '@/lib/di-container';
import { PendingSponsorshipRequestsPresenter } from '@/lib/presenters/PendingSponsorshipRequestsPresenter';
import { useEffectiveDriverId } from '@/lib/currentDriver';
interface SponsorshipSlot {
@@ -72,11 +73,18 @@ export function LeagueSponsorshipsSection({
setRequestsLoading(true);
try {
const useCase = getGetPendingSponsorshipRequestsUseCase();
await useCase.execute({
entityType: 'season',
entityId: seasonId,
});
setPendingRequests(result.requests);
const presenter = new PendingSponsorshipRequestsPresenter();
await useCase.execute(
{
entityType: 'season',
entityId: seasonId,
},
presenter,
);
const viewModel = presenter.getViewModel();
setPendingRequests(viewModel?.requests ?? []);
} catch (err) {
console.error('Failed to load pending requests:', err);
} finally {
@@ -108,7 +116,7 @@ export function LeagueSponsorshipsSection({
await useCase.execute({
requestId,
respondedBy: currentDriverId,
reason,
...(reason ? { reason } : {}),
});
await loadPendingRequests();
} catch (err) {
@@ -118,16 +126,21 @@ export function LeagueSponsorshipsSection({
};
const handleEditPrice = (index: number) => {
const slot = slots[index];
if (!slot) return;
setEditingIndex(index);
setTempPrice(slots[index].price.toString());
setTempPrice(slot.price.toString());
};
const handleSavePrice = (index: number) => {
const price = parseFloat(tempPrice);
if (!isNaN(price) && price > 0) {
const updated = [...slots];
updated[index].price = price;
setSlots(updated);
const slot = updated[index];
if (slot) {
slot.price = price;
setSlots(updated);
}
}
setEditingIndex(null);
setTempPrice('');

View File

@@ -159,13 +159,10 @@ export function LeagueStructureSection({
}
if (nextStructure.mode === 'solo') {
const { maxTeams, driversPerTeam, ...restStructure } = nextStructure;
nextForm = {
...nextForm,
structure: {
...nextStructure,
maxTeams: undefined,
driversPerTeam: undefined,
},
structure: restStructure,
};
}
@@ -178,8 +175,6 @@ export function LeagueStructureSection({
updateStructure({
mode: 'solo',
maxDrivers: structure.maxDrivers || 24,
maxTeams: undefined,
driversPerTeam: undefined,
});
} else {
const maxTeams = structure.maxTeams ?? 12;

View File

@@ -38,7 +38,10 @@ const TIME_ZONES = [
{ value: 'Australia/Sydney', label: 'Sydney (AU)', icon: Globe },
];
type RecurrenceStrategy = NonNullable<LeagueConfigFormModel['timings']>['recurrenceStrategy'];
type RecurrenceStrategy = Exclude<
NonNullable<LeagueConfigFormModel['timings']>['recurrenceStrategy'],
undefined
>;
interface LeagueTimingsSectionProps {
form: LeagueConfigFormModel;
@@ -96,12 +99,16 @@ function RaceDayPreview({
const effectiveRaceTime = raceTime || '20:00';
const getStartTime = (sessionIndex: number) => {
const [hours, minutes] = effectiveRaceTime.split(':').map(Number);
const [hoursStr, minutesStr] = effectiveRaceTime.split(':');
const hours = Number(hoursStr ?? '0');
const minutes = Number(minutesStr ?? '0');
let totalMinutes = hours * 60 + minutes;
const active = allSessions.filter(s => s.active);
const active = allSessions.filter((s) => s.active);
for (let i = 0; i < sessionIndex; i++) {
totalMinutes += active[i].duration + 10; // 10 min break between sessions
const session = active[i];
if (!session) continue;
totalMinutes += session.duration + 10; // 10 min break between sessions
}
const h = Math.floor(totalMinutes / 60) % 24;
@@ -231,10 +238,31 @@ function YearCalendarPreview({
}) {
// JavaScript getDay(): 0=Sunday, 1=Monday, 2=Tuesday, etc.
const dayMap: Record<Weekday, number> = {
'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
] as const;
const getMonthLabel = (index: number): string => months[index] ?? '—';
// Parse start and end dates
const seasonStart = useMemo(() => {
@@ -279,7 +307,8 @@ function YearCalendarPreview({
const spacing = totalPossible / rounds;
for (let i = 0; i < rounds; i++) {
const index = Math.min(Math.floor(i * spacing), totalPossible - 1);
dates.push(allPossibleDays[index]);
const chosen = allPossibleDays[index]!;
dates.push(chosen);
}
} else {
// Not enough days - use all available
@@ -380,7 +409,7 @@ function YearCalendarPreview({
}
view.push({
month: months[targetMonth],
month: months[targetMonth] ?? '—',
monthIndex: targetMonth,
year: targetYear,
days
@@ -391,8 +420,8 @@ function YearCalendarPreview({
}
// Get the range of months that contain races
const firstRaceDate = raceDates[0];
const lastRaceDate = raceDates[raceDates.length - 1];
const firstRaceDate = raceDates[0]!;
const lastRaceDate = raceDates[raceDates.length - 1]!;
// Start from first race month, show 12 months total
const startMonth = firstRaceDate.getMonth();
@@ -414,18 +443,19 @@ function YearCalendarPreview({
rd.getDate() === date.getDate()
);
const isRace = raceIndex >= 0;
const raceNumber = isRace ? raceIndex + 1 : undefined;
days.push({
date,
isRace,
dayOfMonth: day,
isStart: isSeasonStartDate(date),
isEnd: isSeasonEndDate(date),
raceNumber: isRace ? raceIndex + 1 : undefined,
...(raceNumber !== undefined ? { raceNumber } : {}),
});
}
view.push({
month: months[targetMonth],
month: months[targetMonth] ?? '—',
monthIndex: targetMonth,
year: targetYear,
days
@@ -438,9 +468,13 @@ function YearCalendarPreview({
// Calculate season stats
const firstRace = raceDates[0];
const lastRace = raceDates[raceDates.length - 1];
const seasonDurationWeeks = firstRace && lastRace
? Math.ceil((lastRace.getTime() - firstRace.getTime()) / (7 * 24 * 60 * 60 * 1000))
: 0;
const seasonDurationWeeks =
firstRace && lastRace
? Math.ceil(
(lastRace.getTime() - firstRace.getTime()) /
(7 * 24 * 60 * 60 * 1000),
)
: 0;
return (
<div className="space-y-4">
@@ -525,12 +559,18 @@ function YearCalendarPreview({
<div className="text-[9px] text-gray-500">Rounds</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-white">{seasonDurationWeeks || '—'}</div>
<div className="text-lg font-bold text-white">
{seasonDurationWeeks || '—'}
</div>
<div className="text-[9px] text-gray-500">Weeks</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-primary-blue">
{firstRace ? `${months[firstRace.getMonth()]}${months[lastRace?.getMonth() ?? 0]}` : '—'}
{firstRace && lastRace
? `${getMonthLabel(firstRace.getMonth())}${getMonthLabel(
lastRace.getMonth(),
)}`
: '—'}
</div>
<div className="text-[9px] text-gray-500">Duration</div>
</div>
@@ -986,7 +1026,9 @@ export function LeagueTimingsSection({
onClick={() =>
updateTimings({
recurrenceStrategy: opt.id as RecurrenceStrategy,
intervalWeeks: opt.id === 'everyNWeeks' ? 2 : undefined,
...(opt.id === 'everyNWeeks'
? { intervalWeeks: 2 }
: {}),
})
}
className={`
@@ -1054,7 +1096,7 @@ export function LeagueTimingsSection({
<Input
type="date"
value={timings.seasonStartDate ?? ''}
onChange={(e) => updateTimings({ seasonStartDate: e.target.value || undefined })}
onChange={(e) => updateTimings({ seasonStartDate: e.target.value })}
className="bg-iron-gray/30"
/>
</div>
@@ -1066,7 +1108,7 @@ export function LeagueTimingsSection({
<Input
type="date"
value={timings.seasonEndDate ?? ''}
onChange={(e) => updateTimings({ seasonEndDate: e.target.value || undefined })}
onChange={(e) => updateTimings({ seasonEndDate: e.target.value })}
className="bg-iron-gray/30"
/>
</div>
@@ -1086,7 +1128,7 @@ export function LeagueTimingsSection({
<Input
type="time"
value={timings.raceStartTime ?? '20:00'}
onChange={(e) => updateTimings({ raceStartTime: e.target.value || undefined })}
onChange={(e) => updateTimings({ raceStartTime: e.target.value })}
className="bg-iron-gray/30"
/>
</div>
@@ -1214,39 +1256,59 @@ export function LeagueTimingsSection({
{/* Preview content */}
<div className="p-4 min-h-[300px]">
{previewTab === 'day' && (
<RaceDayPreview
template={showSprint ? 'sprintFeature' : 'feature'}
practiceMin={timings.practiceMinutes ?? 20}
qualifyingMin={timings.qualifyingMinutes ?? 15}
sprintMin={showSprint ? (timings.sprintRaceMinutes ?? 20) : undefined}
mainRaceMin={timings.mainRaceMinutes ?? 40}
raceTime={timings.raceStartTime}
/>
)}
{previewTab === 'day' && (() => {
const sprintMinutes = showSprint
? timings.sprintRaceMinutes ?? 20
: undefined;
return (
<RaceDayPreview
template={showSprint ? 'sprintFeature' : 'feature'}
practiceMin={timings.practiceMinutes ?? 20}
qualifyingMin={timings.qualifyingMinutes ?? 15}
{...(sprintMinutes !== undefined
? { sprintMin: sprintMinutes }
: {})}
mainRaceMin={timings.mainRaceMinutes ?? 40}
{...(timings.raceStartTime
? { raceTime: timings.raceStartTime }
: {})}
/>
);
})()}
{previewTab === 'year' && (
<YearCalendarPreview
weekdays={weekdays}
frequency={recurrenceStrategy}
rounds={timings.roundsPlanned ?? 8}
startDate={timings.seasonStartDate}
endDate={timings.seasonEndDate}
{...(timings.seasonStartDate
? { startDate: timings.seasonStartDate }
: {})}
{...(timings.seasonEndDate
? { endDate: timings.seasonEndDate }
: {})}
/>
)}
{previewTab === 'stats' && (
<SeasonStatsPreview
rounds={timings.roundsPlanned ?? 8}
weekdays={weekdays}
frequency={recurrenceStrategy}
weekendTemplate={showSprint ? 'sprintFeature' : 'feature'}
practiceMin={timings.practiceMinutes ?? 20}
qualifyingMin={timings.qualifyingMinutes ?? 15}
sprintMin={showSprint ? (timings.sprintRaceMinutes ?? 20) : undefined}
mainRaceMin={timings.mainRaceMinutes ?? 40}
/>
)}
{previewTab === 'stats' && (() => {
const sprintMinutes = showSprint
? timings.sprintRaceMinutes ?? 20
: undefined;
return (
<SeasonStatsPreview
rounds={timings.roundsPlanned ?? 8}
weekdays={weekdays}
frequency={recurrenceStrategy}
weekendTemplate={showSprint ? 'sprintFeature' : 'feature'}
practiceMin={timings.practiceMinutes ?? 20}
qualifyingMin={timings.qualifyingMinutes ?? 15}
{...(sprintMinutes !== undefined
? { sprintMin: sprintMinutes }
: {})}
mainRaceMin={timings.mainRaceMinutes ?? 40}
/>
);
})()}
</div>
</div>

View File

@@ -229,7 +229,7 @@ export default function ScheduleRaceForm({
</label>
<select
value={formData.sessionType}
onChange={(e) => handleChange('sessionType', e.target.value as SessionType)}
onChange={(e) => handleChange('sessionType', e.target.value)}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="practice">Practice</option>