This commit is contained in:
2025-12-12 14:23:40 +01:00
parent 6a88fe93ab
commit 2cd3bfbb47
58 changed files with 2866 additions and 260 deletions

View File

@@ -21,6 +21,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
import Input from '@/components/ui/Input';
import {
getListLeagueScoringPresetsQuery,
} from '@/lib/di-container';
@@ -52,7 +53,7 @@ import { LeagueStewardingSection } from './LeagueStewardingSection';
const STORAGE_KEY = 'gridpilot_league_wizard_draft';
const STORAGE_HIGHEST_STEP_KEY = 'gridpilot_league_wizard_highest_step';
function saveFormToStorage(form: LeagueConfigFormModel): void {
function saveFormToStorage(form: LeagueWizardFormModel): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
@@ -60,11 +61,16 @@ function saveFormToStorage(form: LeagueConfigFormModel): void {
}
}
function loadFormFromStorage(): LeagueConfigFormModel | null {
function loadFormFromStorage(): LeagueWizardFormModel | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored) as LeagueConfigFormModel;
const parsed = JSON.parse(stored) as LeagueWizardFormModel;
if (!parsed.seasonName) {
const seasonStartDate = parsed.timings?.seasonStartDate;
parsed.seasonName = getDefaultSeasonName(seasonStartDate);
}
return parsed;
}
} catch {
// Ignore parse errors
@@ -105,6 +111,10 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6 | 7;
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review';
type LeagueWizardFormModel = LeagueConfigFormModel & {
seasonName?: string;
};
interface CreateLeagueWizardProps {
stepName: StepName;
onStepChange: (stepName: StepName) => void;
@@ -160,8 +170,21 @@ function getDefaultSeasonStartDate(): string {
return datePart ?? '';
}
function createDefaultForm(): LeagueConfigFormModel {
function getDefaultSeasonName(seasonStartDate?: string): string {
if (seasonStartDate) {
const parsed = new Date(seasonStartDate);
if (!Number.isNaN(parsed.getTime())) {
const year = parsed.getFullYear();
return `Season 1 (${year})`;
}
}
const fallbackYear = new Date().getFullYear();
return `Season 1 (${fallbackYear})`;
}
function createDefaultForm(): LeagueWizardFormModel {
const defaultPatternId = 'sprint-main-driver';
const defaultSeasonStartDate = getDefaultSeasonStartDate();
return {
basics: {
@@ -201,7 +224,7 @@ function createDefaultForm(): LeagueConfigFormModel {
recurrenceStrategy: 'weekly' as const,
raceStartTime: '20:00',
timezoneId: 'UTC',
seasonStartDate: getDefaultSeasonStartDate(),
seasonStartDate: defaultSeasonStartDate,
},
stewarding: {
decisionMode: 'admin_only',
@@ -214,6 +237,7 @@ function createDefaultForm(): LeagueConfigFormModel {
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
},
seasonName: getDefaultSeasonName(defaultSeasonStartDate),
};
}
@@ -229,7 +253,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
const [isHydrated, setIsHydrated] = useState(false);
// Initialize form from localStorage or defaults
const [form, setForm] = useState<LeagueConfigFormModel>(() =>
const [form, setForm] = useState<LeagueWizardFormModel>(() =>
createDefaultForm(),
);
@@ -405,20 +429,30 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
case 2:
return 'Will you compete for global rankings or race with friends?';
case 3:
return 'Will drivers compete individually or as part of teams?';
return 'Define how races in this season will run.';
case 4:
return 'Configure session durations and plan your season calendar.';
return 'Plan when this seasons races happen.';
case 5:
return 'Select a scoring preset, enable championships, and set drop rules.';
return 'Choose how points and drop scores work for this season.';
case 6:
return 'Configure how protests are handled and penalties decided.';
return 'Set how protests and stewarding work for this season.';
case 7:
return 'Everything looks good? Launch your new league!';
return 'Review your league and first season before launching.';
default:
return '';
}
};
const getStepContextLabel = (currentStep: Step): string => {
if (currentStep === 1 || currentStep === 2) {
return 'League setup';
}
if (currentStep >= 3 && currentStep <= 6) {
return 'Season setup';
}
return 'League & Season summary';
};
const currentStepData = steps.find((s) => s.id === step);
const CurrentStepIcon = currentStepData?.icon ?? FileText;
@@ -435,7 +469,10 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
Create a new league
</Heading>
<p className="text-sm text-gray-500">
Set up your racing series in {steps.length} easy steps
We'll also set up your first season in {steps.length} easy steps.
</p>
<p className="text-xs text-gray-500 mt-1">
A league is your long-term brand. Each season is a block of races you can run again and again.
</p>
</div>
</div>
@@ -557,7 +594,12 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
</div>
<div className="flex-1 min-w-0">
<Heading level={2} className="text-xl sm:text-2xl text-white leading-tight">
{getStepTitle(step)}
<div className="flex items-center gap-2 flex-wrap">
<span>{getStepTitle(step)}</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full border border-charcoal-outline bg-iron-gray/60 text-[11px] font-medium text-gray-300">
{getStepContextLabel(step)}
</span>
</div>
</Heading>
<p className="text-sm text-gray-400 mt-1">
{getStepSubtitle(step)}
@@ -575,15 +617,45 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
{/* Step content with min-height for consistency */}
<div className="min-h-[320px]">
{step === 1 && (
<div className="animate-fade-in">
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics ?? {}}
/>
</div>
)}
{step === 1 && (
<div className="animate-fade-in space-y-8">
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics ?? {}}
/>
<div className="rounded-xl border border-charcoal-outline bg-iron-gray/40 p-4">
<div className="flex items-center justify-between gap-2 mb-2">
<div>
<p className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
First season
</p>
<p className="text-xs text-gray-500">
Name the first season that will run in this league.
</p>
</div>
</div>
<div className="space-y-2 mt-2">
<label className="text-sm font-medium text-gray-300">
Season name
</label>
<Input
value={form.seasonName ?? ''}
onChange={(e) =>
setForm((prev) => ({
...prev,
seasonName: e.target.value,
}))
}
placeholder="e.g., Season 1 (2025)"
/>
<p className="text-xs text-gray-500">
Seasons are the individual competitive runs inside your league. You can run Season 2, Season 3, or parallel seasons later.
</p>
</div>
</div>
</div>
)}
{step === 2 && (
<div className="animate-fade-in">
@@ -600,7 +672,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
)}
{step === 3 && (
<div className="animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
<LeagueStructureSection
form={form}
onChange={setForm}
@@ -610,7 +690,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
)}
{step === 4 && (
<div className="animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
<LeagueTimingsSection
form={form}
onChange={setForm}
@@ -621,6 +709,14 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
{step === 5 && (
<div className="animate-fade-in space-y-8">
<div className="mb-2">
<p className="text-xs text-gray-500">
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
{/* Scoring Pattern Selection */}
<ScoringPatternSection
scoring={form.scoring}
@@ -658,7 +754,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
)}
{step === 6 && (
<div className="animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
<LeagueStewardingSection
form={form}
onChange={setForm}
@@ -744,7 +848,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
{/* Helper text */}
<p className="text-center text-xs text-gray-500 mt-4">
You can edit all settings after creating your league
This will create your league and its first season. You can edit both later.
</p>
</form>
);

View File

@@ -15,9 +15,11 @@ import {
loadLeagueProtests,
removeLeagueMember as removeLeagueMemberCommand,
updateLeagueMemberRole as updateLeagueMemberRoleCommand,
loadLeagueSeasons,
type LeagueJoinRequestViewModel,
type LeagueOwnerSummaryViewModel,
type LeagueAdminProtestsViewModel,
type LeagueSeasonSummaryViewModel,
} from '@/lib/presenters/LeagueAdminPresenter';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import type { LeagueSummaryViewModel } from '@/lib/presenters/LeagueAdminPresenter';
@@ -51,13 +53,15 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
const [ownerSummary, setOwnerSummary] = useState<LeagueOwnerSummaryViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'protests' | 'sponsorships' | 'fees' | 'wallet' | 'prizes' | 'liveries'>('members');
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'seasons' | 'settings' | 'protests' | 'sponsorships' | 'fees' | 'wallet' | 'prizes' | 'liveries'>('members');
const [downloadingLiveryPack, setDownloadingLiveryPack] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [configForm, setConfigForm] = useState<LeagueConfigFormModel | null>(null);
const [configLoading, setConfigLoading] = useState(false);
const [protestsViewModel, setProtestsViewModel] = useState<LeagueAdminProtestsViewModel | null>(null);
const [protestsLoading, setProtestsLoading] = useState(false);
const [seasons, setSeasons] = useState<LeagueSeasonSummaryViewModel[]>([]);
const [seasonsLoading, setSeasonsLoading] = useState(false);
const loadJoinRequests = useCallback(async () => {
setLoading(true);
@@ -104,6 +108,22 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
loadConfig();
}, [league.id]);
useEffect(() => {
async function loadSeasonsVm() {
setSeasonsLoading(true);
try {
const items = await loadLeagueSeasons(league.id);
setSeasons(items);
} catch (err) {
console.error('Failed to load seasons:', err);
} finally {
setSeasonsLoading(false);
}
}
loadSeasonsVm();
}, [league.id]);
// Load protests for this league's races
useEffect(() => {
async function loadProtests() {
@@ -257,6 +277,16 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
>
Races
</button>
<button
onClick={() => setActiveTab('seasons')}
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
activeTab === 'seasons'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Seasons
</button>
<button
onClick={() => setActiveTab('sponsorships')}
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
@@ -429,6 +459,101 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
</Card>
)}
{activeTab === 'seasons' && (
<Card>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold text-white">Seasons</h2>
<p className="text-sm text-gray-400 mt-1">
Plan, run, and review seasons inside this league.
</p>
</div>
</div>
{seasonsLoading ? (
<div className="text-center py-8 text-gray-400">Loading seasons</div>
) : seasons.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-iron-gray/50 flex items-center justify-center">
<Calendar className="w-6 h-6 text-gray-500" />
</div>
<p className="text-gray-400 text-sm">No seasons yet.</p>
<p className="text-gray-500 text-xs mt-1">
Your first season is created when you set up the league.
</p>
</div>
) : (
<div className="space-y-3">
{(() => {
const activeCount = seasons.filter((s) => s.status === 'active').length;
return seasons.map((season) => {
const start = season.startDate ? new Date(season.startDate) : null;
const end = season.endDate ? new Date(season.endDate) : null;
const hasParallel = activeCount > 1 && season.status === 'active';
const statusConfig: { label: string; className: string } = (() => {
switch (season.status) {
case 'planned':
return { label: 'Planned', className: 'bg-charcoal-outline/40 text-gray-300 border-charcoal-outline/60' };
case 'active':
return { label: 'Active', className: 'bg-performance-green/15 text-performance-green border-performance-green/40' };
case 'completed':
return { label: 'Completed', className: 'bg-primary-blue/15 text-primary-blue border-primary-blue/40' };
case 'archived':
return { label: 'Archived', className: 'bg-gray-600/20 text-gray-300 border-gray-600/40' };
case 'cancelled':
case 'canceled':
return { label: 'Cancelled', className: 'bg-red-500/10 text-red-400 border-red-500/40' };
default:
return { label: season.status, className: 'bg-charcoal-outline/40 text-gray-300 border-charcoal-outline/60' };
}
})();
return (
<div
key={season.seasonId}
className="rounded-lg border border-charcoal-outline bg-deep-graphite/70 p-4 flex items-start justify-between gap-4"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-semibold text-white truncate">
{season.name}
</h3>
{season.isPrimary && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-primary-blue/10 text-primary-blue border border-primary-blue/40">
Primary
</span>
)}
{hasParallel && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-warning-amber/10 text-warning-amber border border-warning-amber/40">
Parallel
</span>
)}
</div>
<p className="text-xs text-gray-500">
{start
? end
? `${start.toLocaleDateString()} ${end.toLocaleDateString()}`
: `Starts ${start.toLocaleDateString()}`
: 'No dates configured yet'}
</p>
</div>
<div className="flex flex-col items-end gap-1">
<span
className={`px-2 py-0.5 rounded-full text-[10px] font-medium border ${statusConfig.className}`}
>
{statusConfig.label}
</span>
</div>
</div>
);
});
})()}
</div>
)}
</Card>
)}
{activeTab === 'protests' && (
<Card>
<div className="flex items-center justify-between mb-6">

View File

@@ -101,7 +101,8 @@ function FeatureBadge({
}
export default function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) {
const { basics, structure, timings, scoring, championships, dropPolicy } = form;
const { basics, structure, timings, scoring, championships, dropPolicy, stewarding } = form;
const seasonName = (form as LeagueConfigFormModel & { seasonName?: string }).seasonName;
const modeLabel =
structure.mode === 'solo'
@@ -147,10 +148,30 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
}
return { emoji: '✓', label: 'All count', description: 'Every race counts' };
};
const dropRuleInfo = getDropRuleInfo();
const preset = presets.find((p) => p.id === scoring.patternId) ?? null;
const seasonStartLabel =
timings.seasonStartDate
? new Date(timings.seasonStartDate).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: null;
const stewardingLabel = (() => {
switch (stewarding.decisionMode) {
case 'admin_only':
return 'Admin-only decisions';
case 'steward_vote':
return 'Steward panel voting';
default:
return stewarding.decisionMode;
}
})();
const dropRuleInfo = getDropRuleInfo();
const preset = presets.find((p) => p.id === scoring.patternId) ?? null;
const getScoringEmoji = () => {
if (!preset) return '🏁';
@@ -173,91 +194,115 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
(timings.qualifyingMinutes ?? 0) +
(timings.sprintRaceMinutes ?? 0) +
(timings.mainRaceMinutes ?? 0);
return (
<div className="space-y-6">
{/* Hero Banner */}
<div className="relative rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray to-iron-gray border border-primary-blue/30 p-6 overflow-hidden">
{/* Background decoration */}
<div className="absolute top-0 right-0 w-32 h-32 bg-primary-blue/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-24 h-24 bg-neon-aqua/5 rounded-full blur-2xl" />
<div className="relative flex items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/20 border border-primary-blue/30 shrink-0">
<Rocket className="w-7 h-7 text-primary-blue" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl font-bold text-white mb-1 truncate">
{basics.name || 'Your New League'}
</h2>
<p className="text-sm text-gray-400 mb-3">
{basics.description || 'Ready to launch your racing series!'}
</p>
<div className="flex flex-wrap items-center gap-3">
{/* Ranked/Unranked Badge */}
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ${
isRanked
? 'bg-primary-blue/15 text-primary-blue border border-primary-blue/30'
: 'bg-neon-aqua/15 text-neon-aqua border border-neon-aqua/30'
}`}>
{isRanked ? <Trophy className="w-3 h-3" /> : <Users className="w-3 h-3" />}
<span className="font-semibold">{visibilityLabel}</span>
<span className="text-[10px] opacity-70"> {visibilityDescription}</span>
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
<Gamepad2 className="w-3 h-3" />
iRacing
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
{structure.mode === 'solo' ? <User className="w-3 h-3" /> : <UsersRound className="w-3 h-3" />}
{modeLabel}
</span>
{/* League Summary */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-300">League summary</h3>
<div className="relative rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray to-iron-gray border border-primary-blue/30 p-6 overflow-hidden">
{/* Background decoration */}
<div className="absolute top-0 right-0 w-32 h-32 bg-primary-blue/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-24 h-24 bg-neon-aqua/5 rounded-full blur-2xl" />
<div className="relative flex items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/20 border border-primary-blue/30 shrink-0">
<Rocket className="w-7 h-7 text-primary-blue" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl font-bold text-white mb-1 truncate">
{basics.name || 'Your New League'}
</h2>
<p className="text-sm text-gray-400 mb-3">
{basics.description || 'Ready to launch your racing series!'}
</p>
<div className="flex flex-wrap items-center gap-3">
{/* Ranked/Unranked Badge */}
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ${
isRanked
? 'bg-primary-blue/15 text-primary-blue border border-primary-blue/30'
: 'bg-neon-aqua/15 text-neon-aqua border border-neon-aqua/30'
}`}>
{isRanked ? <Trophy className="w-3 h-3" /> : <Users className="w-3 h-3" />}
<span className="font-semibold">{visibilityLabel}</span>
<span className="text-[10px] opacity-70"> {visibilityDescription}</span>
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
<Gamepad2 className="w-3 h-3" />
iRacing
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
{structure.mode === 'solo' ? <User className="w-3 h-3" /> : <UsersRound className="w-3 h-3" />}
{modeLabel}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Capacity */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 mx-auto mb-2">
<Users className="w-5 h-5 text-primary-blue" />
</div>
<div className="text-2xl font-bold text-white">{capacityValue}</div>
<div className="text-xs text-gray-500">{capacityLabel}</div>
{/* Season Summary */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-300">First season summary</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-400">
<span>{seasonName || 'First season of this league'}</span>
{seasonStartLabel && (
<>
<span></span>
<span>Starts {seasonStartLabel}</span>
</>
)}
{typeof timings.roundsPlanned === 'number' && (
<>
<span></span>
<span>{timings.roundsPlanned} rounds planned</span>
</>
)}
<span></span>
<span>Stewarding: {stewardingLabel}</span>
</div>
{/* Rounds */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10 mx-auto mb-2">
<Flag className="w-5 h-5 text-performance-green" />
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Capacity */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 mx-auto mb-2">
<Users className="w-5 h-5 text-primary-blue" />
</div>
<div className="text-2xl font-bold text-white">{capacityValue}</div>
<div className="text-xs text-gray-500">{capacityLabel}</div>
</div>
<div className="text-2xl font-bold text-white">{timings.roundsPlanned ?? '—'}</div>
<div className="text-xs text-gray-500">rounds</div>
</div>
{/* Weekend Duration */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10 mx-auto mb-2">
<Timer className="w-5 h-5 text-warning-amber" />
{/* Rounds */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10 mx-auto mb-2">
<Flag className="w-5 h-5 text-performance-green" />
</div>
<div className="text-2xl font-bold text-white">{timings.roundsPlanned ?? '—'}</div>
<div className="text-xs text-gray-500">rounds</div>
</div>
<div className="text-2xl font-bold text-white">{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}</div>
<div className="text-xs text-gray-500">min/weekend</div>
</div>
{/* Championships */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-aqua/10 mx-auto mb-2">
<Award className="w-5 h-5 text-neon-aqua" />
{/* Weekend Duration */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10 mx-auto mb-2">
<Timer className="w-5 h-5 text-warning-amber" />
</div>
<div className="text-2xl font-bold text-white">{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}</div>
<div className="text-xs text-gray-500">min/weekend</div>
</div>
<div className="text-2xl font-bold text-white">
{[championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].filter(Boolean).length}
{/* Championships */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-aqua/10 mx-auto mb-2">
<Award className="w-5 h-5 text-neon-aqua" />
</div>
<div className="text-2xl font-bold text-white">
{[championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].filter(Boolean).length}
</div>
<div className="text-xs text-gray-500">championships</div>
</div>
<div className="text-xs text-gray-500">championships</div>
</div>
</div>
{/* Detail Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Schedule Card */}
@@ -273,7 +318,7 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
<InfoRow icon={Flag} label="Main Race" value={formatMinutes(timings.mainRaceMinutes)} />
</div>
</ReviewCard>
{/* Scoring Card */}
<ReviewCard icon={Trophy} iconColor="text-warning-amber" bgColor="bg-warning-amber/10" title="Scoring System">
<div className="space-y-3">
@@ -288,7 +333,7 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
<span className="px-2 py-0.5 rounded bg-primary-blue/20 text-[10px] font-medium text-primary-blue">Custom</span>
)}
</div>
{/* Drop Rule */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-charcoal-outline/50">
@@ -302,7 +347,7 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
</div>
</ReviewCard>
</div>
{/* Championships Section */}
<ReviewCard icon={Award} iconColor="text-neon-aqua" bgColor="bg-neon-aqua/10" title="Active Championships">
<div className="flex flex-wrap gap-2">
@@ -339,7 +384,7 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
)}
</div>
</ReviewCard>
{/* Ready to launch message */}
<div className="rounded-xl bg-performance-green/5 border border-performance-green/20 p-4">
<div className="flex items-center gap-3">

View File

@@ -167,7 +167,10 @@ export function LeagueSponsorshipsSection({
<div>
<h3 className="text-lg font-semibold text-white">Sponsorships</h3>
<p className="text-sm text-gray-400 mt-1">
Define pricing for main and secondary sponsor slots
Define pricing for sponsor slots in this league. Sponsors pay per season.
</p>
<p className="text-xs text-gray-500 mt-1">
These sponsors are attached to seasons in this league, so you can change partners from season to season.
</p>
</div>
{!readOnly && (

View File

@@ -79,7 +79,7 @@ export default function PendingSponsorshipRequests({
</div>
<p className="text-gray-400 text-sm">No pending sponsorship requests</p>
<p className="text-gray-500 text-xs mt-1">
When sponsors apply to sponsor this {entityType}, their requests will appear here.
When sponsors apply to sponsor this {entityType}, their requests will appear here. Sponsorships are attached to seasons, so you can change partners from season to season.
</p>
</div>
);
@@ -233,7 +233,7 @@ export default function PendingSponsorshipRequests({
<div className="text-xs text-gray-500 mt-4">
<p>
<strong className="text-gray-400">Note:</strong> Accepting a request will activate the sponsorship.
The sponsor will be charged and you'll receive the payment minus 10% platform fee.
The sponsor will be charged per season and you'll receive the payment minus 10% platform fee.
</p>
</div>
</div>

View File

@@ -127,6 +127,13 @@ function getEntityIcon(type: EntityType) {
}
}
function getSponsorshipTagline(type: EntityType): string {
if (type === 'league') {
return 'Reach engaged sim racers by sponsoring a season in this league.';
}
return `Reach engaged sim racers by sponsoring this ${getEntityLabel(type).toLowerCase()}`;
}
// ============================================================================
// COMPONENT
// ============================================================================
@@ -227,7 +234,7 @@ export default function SponsorInsightsCard({
<h3 className="text-lg font-semibold text-white">Sponsorship Opportunity</h3>
</div>
<p className="text-gray-400 text-sm">
Reach engaged sim racers by sponsoring this {entityType}
{getSponsorshipTagline(entityType)}
</p>
</div>
<div className="flex items-center gap-2">
@@ -412,7 +419,7 @@ export default function SponsorInsightsCard({
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50">
<p className="text-xs text-gray-500">
10% platform fee applies Logos burned on all liveries
10% platform fee applies Logos burned on all liveries Sponsorships are attached to seasons, so you can change partners from season to season
{appliedTiers.size > 0 && ' • Application pending review'}
</p>
<Button

View File

@@ -113,6 +113,7 @@ import {
AcceptSponsorshipRequestUseCase,
RejectSponsorshipRequestUseCase,
} from '@gridpilot/racing/application';
import { ListSeasonsForLeagueUseCase } from '@gridpilot/racing/application/use-cases/SeasonUseCases';
import { GetDashboardOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetDashboardOverviewUseCase';
import { GetProfileOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetProfileOverviewUseCase';
import { UpdateDriverProfileUseCase } from '@gridpilot/racing/application/use-cases/UpdateDriverProfileUseCase';
@@ -1007,6 +1008,14 @@ export function configureDIContainer(): void {
)
);
container.registerInstance(
DI_TOKENS.ListSeasonsForLeagueUseCase,
new ListSeasonsForLeagueUseCase(
leagueRepository,
seasonRepository,
)
);
const leagueScoringPresetsPresenter = new LeagueScoringPresetsPresenter();
container.registerInstance(
DI_TOKENS.ListLeagueScoringPresetsUseCase,
@@ -1349,7 +1358,11 @@ export function configureDIContainer(): void {
container.registerInstance(
DI_TOKENS.AcceptSponsorshipRequestUseCase,
new AcceptSponsorshipRequestUseCase(sponsorshipRequestRepository, seasonSponsorshipRepository)
new AcceptSponsorshipRequestUseCase(
sponsorshipRequestRepository,
seasonSponsorshipRepository,
seasonRepository,
)
);
container.registerInstance(

View File

@@ -100,6 +100,7 @@ import type { PreviewLeagueScheduleUseCase } from '@gridpilot/racing/application
import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { GetDashboardOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetDashboardOverviewUseCase';
import type { ListSeasonsForLeagueUseCase } from '@gridpilot/racing/application/use-cases/SeasonUseCases';
import { createDemoDriverStats, getDemoLeagueRankings, type DriverStats } from '@gridpilot/testing-support';
/**
@@ -254,6 +255,11 @@ class DIContainer {
return getDIContainer().resolve<GetAllLeaguesWithCapacityAndScoringUseCase>(DI_TOKENS.GetAllLeaguesWithCapacityAndScoringUseCase);
}
get listSeasonsForLeagueUseCase(): ListSeasonsForLeagueUseCase {
this.ensureInitialized();
return getDIContainer().resolve<ListSeasonsForLeagueUseCase>(DI_TOKENS.ListSeasonsForLeagueUseCase);
}
get listLeagueScoringPresetsUseCase(): ListLeagueScoringPresetsUseCase {
this.ensureInitialized();
return getDIContainer().resolve<ListLeagueScoringPresetsUseCase>(DI_TOKENS.ListLeagueScoringPresetsUseCase);
@@ -650,6 +656,10 @@ export function getGetAllLeaguesWithCapacityAndScoringUseCase(): GetAllLeaguesWi
return DIContainer.getInstance().getAllLeaguesWithCapacityAndScoringUseCase;
}
export function getListSeasonsForLeagueUseCase(): ListSeasonsForLeagueUseCase {
return DIContainer.getInstance().listSeasonsForLeagueUseCase;
}
export function getGetLeagueScoringConfigUseCase(): GetLeagueScoringConfigUseCase {
return DIContainer.getInstance().getLeagueScoringConfigUseCase;
}

View File

@@ -82,6 +82,7 @@ export const DI_TOKENS = {
PreviewLeagueScheduleUseCase: Symbol.for('PreviewLeagueScheduleUseCase'),
GetRaceWithSOFUseCase: Symbol.for('GetRaceWithSOFUseCase'),
GetLeagueStatsUseCase: Symbol.for('GetLeagueStatsUseCase'),
ListSeasonsForLeagueUseCase: Symbol.for('ListSeasonsForLeagueUseCase'),
GetRacesPageDataUseCase: Symbol.for('GetRacesPageDataUseCase'),
GetAllRacesPageDataUseCase: Symbol.for('GetAllRacesPageDataUseCase'),
GetRaceDetailUseCase: Symbol.for('GetRaceDetailUseCase'),

View File

@@ -15,6 +15,7 @@ import {
getProtestRepository,
getDriverStats,
getAllDriverRankings,
getListSeasonsForLeagueUseCase,
} from '@/lib/di-container';
export interface LeagueJoinRequestViewModel {
@@ -63,6 +64,16 @@ export interface LeagueAdminPermissionsViewModel {
canUpdateRoles: boolean;
}
export interface LeagueSeasonSummaryViewModel {
seasonId: string;
name: string;
status: string;
startDate?: Date;
endDate?: Date;
isPrimary: boolean;
isParallelActive: boolean;
}
export interface LeagueAdminViewModel {
joinRequests: LeagueJoinRequestViewModel[];
ownerSummary: LeagueOwnerSummaryViewModel | null;
@@ -356,4 +367,20 @@ export async function loadLeagueProtests(leagueId: string): Promise<LeagueAdminP
racesById,
driversById,
};
}
export async function loadLeagueSeasons(leagueId: string): Promise<LeagueSeasonSummaryViewModel[]> {
const useCase = getListSeasonsForLeagueUseCase();
const result = await useCase.execute({ leagueId });
const activeCount = result.items.filter((s) => s.status === 'active').length;
return result.items.map((s) => ({
seasonId: s.seasonId,
name: s.name,
status: s.status,
...(s.startDate ? { startDate: s.startDate } : {}),
...(s.endDate ? { endDate: s.endDate } : {}),
isPrimary: s.isPrimary ?? false,
isParallelActive: activeCount > 1 && s.status === 'active',
}));
}

View File

@@ -30,15 +30,19 @@ export class RecordEngagementUseCase
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
const eventId = `eng-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const event = EngagementEvent.create({
const baseProps: Omit<Parameters<typeof EngagementEvent.create>[0], 'timestamp'> = {
id: eventId,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
actorId: input.actorId,
actorType: input.actorType,
sessionId: input.sessionId,
metadata: input.metadata,
};
const event = EngagementEvent.create({
...baseProps,
...(input.actorId !== undefined ? { actorId: input.actorId } : {}),
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
});
await this.engagementRepository.save(event);

View File

@@ -31,16 +31,20 @@ export class RecordPageViewUseCase
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
const pageViewId = `pv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const pageView = PageView.create({
const baseProps: Omit<Parameters<typeof PageView.create>[0], 'timestamp'> = {
id: pageViewId,
entityType: input.entityType,
entityId: input.entityId,
visitorId: input.visitorId,
visitorType: input.visitorType,
sessionId: input.sessionId,
referrer: input.referrer,
userAgent: input.userAgent,
country: input.country,
};
const pageView = PageView.create({
...baseProps,
...(input.visitorId !== undefined ? { visitorId: input.visitorId } : {}),
...(input.referrer !== undefined ? { referrer: input.referrer } : {}),
...(input.userAgent !== undefined ? { userAgent: input.userAgent } : {}),
...(input.country !== undefined ? { country: input.country } : {}),
});
await this.pageViewRepository.save(pageView);

View File

@@ -12,6 +12,7 @@ import type {
SnapshotEntityType,
SnapshotPeriod,
} from '../types/AnalyticsSnapshot';
export type { SnapshotEntityType, SnapshotPeriod } from '../types/AnalyticsSnapshot';
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
export class AnalyticsSnapshot implements IEntity<string> {

View File

@@ -56,10 +56,21 @@ export class PageView implements IEntity<string> {
static create(props: Omit<PageViewProps, 'timestamp'> & { timestamp?: Date }): PageView {
this.validate(props);
return new PageView({
...props,
const baseProps: PageViewProps = {
id: props.id,
entityType: props.entityType,
entityId: props.entityId,
visitorType: props.visitorType,
sessionId: props.sessionId,
timestamp: props.timestamp ?? new Date(),
});
...(props.visitorId !== undefined ? { visitorId: props.visitorId } : {}),
...(props.referrer !== undefined ? { referrer: props.referrer } : {}),
...(props.userAgent !== undefined ? { userAgent: props.userAgent } : {}),
...(props.country !== undefined ? { country: props.country } : {}),
...(props.durationMs !== undefined ? { durationMs: props.durationMs } : {}),
};
return new PageView(baseProps);
}
private static validate(props: Omit<PageViewProps, 'timestamp'>): void {
@@ -88,8 +99,17 @@ export class PageView implements IEntity<string> {
throw new Error('Duration must be non-negative');
}
return new PageView({
...this,
return PageView.create({
id: this.id,
entityType: this.entityType,
entityId: this.entityId,
visitorType: this.visitorType,
sessionId: this.sessionId,
timestamp: this.timestamp,
...(this.visitorId !== undefined ? { visitorId: this.visitorId } : {}),
...(this.referrer !== undefined ? { referrer: this.referrer } : {}),
...(this.userAgent !== undefined ? { userAgent: this.userAgent } : {}),
...(this.country !== undefined ? { country: this.country } : {}),
durationMs,
});
}

View File

@@ -24,14 +24,16 @@ export class StartAutomationSessionUseCase
await this.sessionRepository.save(session);
return {
const dto: SessionDTO = {
sessionId: session.id,
state: session.state.value,
currentStep: session.currentStep.value,
config: session.config,
startedAt: session.startedAt,
completedAt: session.completedAt,
errorMessage: session.errorMessage,
...(session.startedAt ? { startedAt: session.startedAt } : {}),
...(session.completedAt ? { completedAt: session.completedAt } : {}),
...(session.errorMessage ? { errorMessage: session.errorMessage } : {}),
};
return dto;
}
}

View File

@@ -20,6 +20,18 @@ export class CheckoutConfirmation {
return new CheckoutConfirmation(value);
}
static confirmed(): CheckoutConfirmation {
return CheckoutConfirmation.create('confirmed');
}
static cancelled(_reason?: string): CheckoutConfirmation {
return CheckoutConfirmation.create('cancelled');
}
static timeout(): CheckoutConfirmation {
return CheckoutConfirmation.create('timeout');
}
get value(): CheckoutConfirmationDecision {
return this._value;
}

View File

@@ -54,10 +54,6 @@ export class SessionState implements IValueObject<SessionStateProps> {
return this._value;
}
equals(other: SessionState): boolean {
return this._value === other._value;
}
isPending(): boolean {
return this._value === 'PENDING';
}

View File

@@ -25,10 +25,6 @@ export class StepId implements IValueObject<StepIdProps> {
return this._value;
}
equals(other: StepId): boolean {
return this._value === other._value;
}
isModalStep(): boolean {
return this._value === 6 || this._value === 9 || this._value === 12;
}

View File

@@ -27,10 +27,10 @@ interface PlaywrightAuthSessionConfig {
* - Exposing the IAuthenticationService port for application layer
*/
export class PlaywrightAuthSessionService implements AuthenticationServicePort {
private readonly browserSession: PlaywrightBrowserSession;
private readonly cookieStore: SessionCookieStore;
private readonly authFlow: IPlaywrightAuthFlow;
private readonly logger?: LoggerPort;
private readonly browserSession: PlaywrightBrowserSession;
private readonly cookieStore: SessionCookieStore;
private readonly authFlow: IPlaywrightAuthFlow;
private readonly logger: LoggerPort | undefined;
private readonly navigationTimeoutMs: number;
private readonly loginWaitTimeoutMs: number;

View File

@@ -43,9 +43,9 @@ const EXPIRY_BUFFER_SECONDS = 300;
export class SessionCookieStore {
private readonly storagePath: string;
private logger?: LoggerPort;
private readonly logger: LoggerPort | undefined;
constructor(userDataDir: string, logger?: LoggerPort) {
constructor(userDataDir: string, logger: LoggerPort | undefined) {
this.storagePath = path.join(userDataDir, 'session-state.json');
this.logger = logger;
}

View File

@@ -428,7 +428,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
private browserSession: PlaywrightBrowserSession;
private connected = false;
private isConnecting = false;
private logger?: LoggerPort;
private logger: LoggerPort | undefined;
private cookieStore: SessionCookieStore;
private authService: PlaywrightAuthSessionService;
private overlayInjected = false;
@@ -438,7 +438,8 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
private static readonly PAUSE_CHECK_INTERVAL = 300;
/** Checkout confirmation callback - called before clicking checkout button */
private checkoutConfirmationCallback?: (price: CheckoutPrice, state: CheckoutState) => Promise<CheckoutConfirmation>;
private checkoutConfirmationCallback: (price: CheckoutPrice, state: CheckoutState) => Promise<CheckoutConfirmation> =
async () => CheckoutConfirmation.cancelled('No checkout confirmation callback configured');
/** Page state validator instance */
private pageStateValidator: PageStateValidator;
@@ -448,7 +449,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
private domInteractor!: IRacingDomInteractor;
private readonly stepOrchestrator: WizardStepOrchestrator;
constructor(config: PlaywrightConfig = {}, logger?: LoggerPort, browserModeLoader?: BrowserModeConfigLoader) {
constructor(config: PlaywrightConfig = {}, logger: LoggerPort | undefined, browserModeLoader?: BrowserModeConfigLoader) {
this.config = {
headless: true,
timeout: 10000,
@@ -627,7 +628,8 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
async connect(forceHeaded: boolean = false): Promise<AutomationResultDTO> {
const result = await this.browserSession.connect(forceHeaded);
if (!result.success) {
return { success: false, error: result.error };
const errorMessage = result.error ?? 'Unknown automation connection error';
return { success: false, error: errorMessage };
}
this.syncSessionStateFromBrowser();
@@ -1333,12 +1335,10 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
// Get ONLY the direct text of this element, excluding child element text
// This prevents false positives when a checkout button exists elsewhere on the page
const directText = await element.evaluate((el) => {
// Get only direct text nodes, not text from child elements
const directText = await element.evaluate((el: Node) => {
let text = '';
const childNodes = Array.from(el.childNodes);
for (let i = 0; i < childNodes.length; i++) {
const node = childNodes[i];
const childNodes = Array.from((el as HTMLElement).childNodes);
for (const node of childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent || '';
}
@@ -2296,8 +2296,8 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null;
if (!el) return;
el.value = val;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
(el as any).dispatchEvent(new Event('input', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
}, { sel: selector, val: value });
return { success: true, fieldName, valueSet: value };
} catch (evalErr) {
@@ -2495,11 +2495,11 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
// If element is a checkbox/input, set checked; otherwise try to toggle aria-checked or click
if ('checked' in el) {
(el as HTMLInputElement).checked = Boolean(should);
el.dispatchEvent(new Event('change', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
} else {
// Fallback: set aria-checked attribute and dispatch click
(el as HTMLElement).setAttribute('aria-checked', String(Boolean(should)));
el.dispatchEvent(new Event('change', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
try { (el as HTMLElement).click(); } catch { /* ignore */ }
}
} catch {
@@ -3013,7 +3013,12 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
setCheckoutConfirmationCallback(
callback?: (price: CheckoutPrice, state: CheckoutState) => Promise<CheckoutConfirmation>
): void {
this.checkoutConfirmationCallback = callback;
if (callback) {
this.checkoutConfirmationCallback = callback;
} else {
this.checkoutConfirmationCallback = async () =>
CheckoutConfirmation.cancelled('No checkout confirmation callback configured');
}
}
// ===== Overlay Methods =====
@@ -3085,7 +3090,13 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
const progress = Math.round((step / this.totalSteps) * 100);
const personality = OVERLAY_PERSONALITY_MESSAGES[Math.floor(Math.random() * OVERLAY_PERSONALITY_MESSAGES.length)];
await this.page.evaluate(({ actionMsg, progressPct, stepNum, totalSteps, personalityMsg }) => {
await this.page.evaluate(({ actionMsg, progressPct, stepNum, totalSteps, personalityMsg }: {
actionMsg: string;
progressPct: number;
stepNum: number;
totalSteps: number;
personalityMsg: string | undefined;
}) => {
const actionEl = document.getElementById('gridpilot-action');
const progressEl = document.getElementById('gridpilot-progress');
const stepTextEl = document.getElementById('gridpilot-step-text');
@@ -3094,9 +3105,9 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
if (actionEl) actionEl.textContent = actionMsg;
if (progressEl) progressEl.style.width = `${progressPct}%`;
if (stepTextEl) stepTextEl.textContent = actionMsg;
if (stepTextEl) stepTextEl.textContent = actionMsg ?? '';
if (stepCountEl) stepCountEl.textContent = `Step ${stepNum} of ${totalSteps}`;
if (personalityEl) personalityEl.textContent = personalityMsg;
if (personalityEl) personalityEl.textContent = personalityMsg ?? '';
}, {
actionMsg: actionMessage,
progressPct: progress,

View File

@@ -26,7 +26,7 @@ interface WizardStepOrchestratorDeps {
navigator: IRacingDomNavigator;
interactor: IRacingDomInteractor;
authService: AuthenticationServicePort;
logger?: LoggerPort;
logger?: LoggerPort | undefined;
totalSteps: number;
getCheckoutConfirmationCallback: () =>
| ((
@@ -68,7 +68,7 @@ export class WizardStepOrchestrator {
private readonly navigator: IRacingDomNavigator;
private readonly interactor: IRacingDomInteractor;
private readonly authService: AuthenticationServicePort;
private readonly logger?: LoggerPort;
private readonly logger: LoggerPort | undefined;
private readonly totalSteps: number;
private readonly getCheckoutConfirmationCallbackInternal: WizardStepOrchestratorDeps['getCheckoutConfirmationCallback'];
private readonly overlay: WizardStepOrchestratorDeps['overlay'];

View File

@@ -63,7 +63,7 @@ export class IRacingDomInteractor {
return { success: false, fieldName, valueSet: value, error: `Unknown form field: ${fieldName}` };
}
const selector = fieldMap[fieldName];
const selector = fieldMap[fieldName as keyof typeof fieldMap] ?? IRACING_SELECTORS.fields.textInput;
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
this.log('debug', 'Filling form field', { fieldName, selector, mode: this.config.mode });
@@ -90,8 +90,8 @@ export class IRacingDomInteractor {
const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null;
if (!el) return;
el.value = val;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
(el as any).dispatchEvent(new Event('input', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
}, { sel: selector, val: value });
return { success: true, fieldName, valueSet: value };
} catch (evalErr) {
@@ -514,10 +514,10 @@ export class IRacingDomInteractor {
try {
if ('checked' in el) {
(el as HTMLInputElement).checked = Boolean(should);
el.dispatchEvent(new Event('change', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
} else {
(el as HTMLElement).setAttribute('aria-checked', String(Boolean(should)));
el.dispatchEvent(new Event('change', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
try {
(el as HTMLElement).click();
} catch {
@@ -621,8 +621,10 @@ export class IRacingDomInteractor {
try {
el.value = String(val);
el.setAttribute('data-value', String(val));
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
const inputEvent = new Event('input', { bubbles: true });
const changeEvent = new Event('change', { bubbles: true });
(el as any).dispatchEvent(inputEvent);
(el as any).dispatchEvent(changeEvent);
} catch {
// ignore
}

View File

@@ -67,10 +67,13 @@ export class IRacingDomNavigator {
if (!this.isRealMode()) {
const stepMatch = url.match(/step-(\d+)-/);
if (stepMatch) {
const stepNumber = parseInt(stepMatch[1], 10);
await page.evaluate((step) => {
document.body.setAttribute('data-step', String(step));
}, stepNumber);
const [, stepStr] = stepMatch;
if (stepStr) {
const stepNumber = parseInt(stepStr, 10);
await page.evaluate((step) => {
document.body.setAttribute('data-step', String(step));
}, stepNumber);
}
}
}

View File

@@ -136,10 +136,9 @@ export class SafeClickService {
.evaluate((el) => {
let text = '';
const childNodes = Array.from(el.childNodes);
for (let i = 0; i < childNodes.length; i++) {
const node = childNodes[i];
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent || '';
for (const child of childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
text += child.textContent || '';
}
}
return text.trim();

View File

@@ -2,7 +2,6 @@ import type { AutomationEnginePort } from '../../../../application/ports/Automat
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
import { StepId } from '../../../../domain/value-objects/StepId';
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
import { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';

View File

@@ -105,7 +105,7 @@ export class FixtureServer implements IFixtureServer {
let fileName: string;
if (urlPath === '/') {
fileName = STEP_TO_FIXTURE[1];
fileName = STEP_TO_FIXTURE[1] ?? '01-hosted-racing.html';
} else {
fileName = urlPath.replace(/^\//, '');

View File

@@ -2,7 +2,6 @@ import type { AutomationEnginePort } from '../../../../application/ports/Automat
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
import { StepId } from '../../../../domain/value-objects/StepId';
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
import { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';

View File

@@ -6,7 +6,7 @@ import type { ClickResultDTO } from '../../../../application/dto/ClickResultDTO'
import type { WaitResultDTO } from '../../../../application/dto/WaitResultDTO';
import type { ModalResultDTO } from '../../../../application/dto/ModalResultDTO';
import type { AutomationResultDTO } from '../../../../application/dto/AutomationResultDTO';
import type { IAutomationLifecycleEmitter, LifecycleCallback } from '../../../IAutomationLifecycleEmitter';
import type { IAutomationLifecycleEmitter, LifecycleCallback } from '../../IAutomationLifecycleEmitter';
interface MockConfig {
simulateFailures?: boolean;

View File

@@ -38,7 +38,7 @@ export class Achievement implements IEntity<string> {
readonly description: string;
readonly category: AchievementCategory;
readonly rarity: AchievementRarity;
readonly iconUrl?: string;
readonly iconUrl: string;
readonly points: number;
readonly requirements: AchievementRequirement[];
readonly isSecret: boolean;
@@ -50,7 +50,7 @@ export class Achievement implements IEntity<string> {
this.description = props.description;
this.category = props.category;
this.rarity = props.rarity;
this.iconUrl = props.iconUrl;
this.iconUrl = props.iconUrl ?? '';
this.points = props.points;
this.requirements = props.requirements;
this.isSecret = props.isSecret;

View File

@@ -16,7 +16,7 @@ export interface SponsorAccountProps {
passwordHash: string;
companyName: string;
isActive: boolean;
createdAt: Date;
createdAt?: Date;
lastLoginAt?: Date;
}
@@ -37,7 +37,7 @@ export class SponsorAccount {
this.passwordHash = props.passwordHash;
this.companyName = props.companyName;
this.isActive = props.isActive;
this.createdAt = props.createdAt;
this.createdAt = props.createdAt ?? new Date();
this.lastLoginAt = props.lastLoginAt;
}
@@ -128,7 +128,7 @@ export class SponsorAccount {
companyName: this.companyName,
isActive: false,
createdAt: this.createdAt,
lastLoginAt: this.lastLoginAt,
...(this.lastLoginAt ? { lastLoginAt: this.lastLoginAt } : {}),
});
}
@@ -145,7 +145,7 @@ export class SponsorAccount {
companyName: this.companyName,
isActive: this.isActive,
createdAt: this.createdAt,
lastLoginAt: this.lastLoginAt,
...(this.lastLoginAt ? { lastLoginAt: this.lastLoginAt } : {}),
});
}
}

View File

@@ -14,10 +14,10 @@ export interface UserProps {
export class User {
private readonly id: UserId;
private displayName: string;
private email?: string;
private iracingCustomerId?: string;
private primaryDriverId?: string;
private avatarUrl?: string;
private email: string | undefined;
private iracingCustomerId: string | undefined;
private primaryDriverId: string | undefined;
private avatarUrl: string | undefined;
private constructor(props: UserProps) {
if (!props.displayName || !props.displayName.trim()) {

View File

@@ -20,7 +20,7 @@ export class UserAchievement implements IEntity<string> {
readonly userId: string;
readonly achievementId: string;
readonly earnedAt: Date;
readonly notifiedAt?: Date;
readonly notifiedAt: Date | undefined;
readonly progress: number;
private constructor(props: UserAchievementProps) {
@@ -53,8 +53,12 @@ export class UserAchievement implements IEntity<string> {
*/
markNotified(): UserAchievement {
return new UserAchievement({
...this,
id: this.id,
userId: this.userId,
achievementId: this.achievementId,
earnedAt: this.earnedAt,
notifiedAt: new Date(),
progress: this.progress,
});
}
@@ -64,7 +68,11 @@ export class UserAchievement implements IEntity<string> {
updateProgress(progress: number): UserAchievement {
const clampedProgress = Math.max(0, Math.min(100, progress));
return new UserAchievement({
...this,
id: this.id,
userId: this.userId,
achievementId: this.achievementId,
earnedAt: this.earnedAt,
...(this.notifiedAt ? { notifiedAt: this.notifiedAt } : {}),
progress: clampedProgress,
});
}

View File

@@ -18,7 +18,7 @@ export interface DiscordAdapterConfig {
export class DiscordNotificationAdapter implements INotificationGateway {
private readonly channel: NotificationChannel = 'discord';
private webhookUrl?: string;
private webhookUrl: string | undefined;
constructor(config: DiscordAdapterConfig = {}) {
this.webhookUrl = config.webhookUrl;

View File

@@ -7,6 +7,7 @@
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import type { AsyncUseCase } from '@gridpilot/shared/application';
@@ -29,6 +30,7 @@ export class AcceptSponsorshipRequestUseCase
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(dto: AcceptSponsorshipRequestDTO): Promise<AcceptSponsorshipRequestResultDTO> {
@@ -50,9 +52,15 @@ export class AcceptSponsorshipRequestUseCase
let sponsorshipId = `spons_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (request.entityType === 'season') {
const season = await this.seasonRepository.findById(request.entityId);
if (!season) {
throw new Error('Season not found for sponsorship request');
}
const sponsorship = SeasonSponsorship.create({
id: sponsorshipId,
seasonId: request.entityId,
seasonId: season.id,
leagueId: season.leagueId,
sponsorId: request.sponsorId,
tier: request.tier,
pricing: request.offeredAmount,

View File

@@ -0,0 +1,460 @@
import { Season } from '../../domain/entities/Season';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO';
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
import { SeasonScoringConfig } from '../../domain/value-objects/SeasonScoringConfig';
import { SeasonDropPolicy } from '../../domain/value-objects/SeasonDropPolicy';
import { SeasonStewardingConfig } from '../../domain/value-objects/SeasonStewardingConfig';
import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
import type { Weekday } from '../../domain/types/Weekday';
import { normalizeVisibility } from '../dto/LeagueConfigFormDTO';
import { LeagueVisibility } from '../../domain/value-objects/LeagueVisibility';
import { v4 as uuidv4 } from 'uuid';
/**
* DTOs and helpers shared across Season-focused use cases.
*/
export interface CreateSeasonForLeagueCommand {
leagueId: string;
name: string;
gameId: string;
sourceSeasonId?: string;
/**
* Optional high-level wizard config used to derive schedule/scoring/drop/stewarding.
* When omitted, the Season will be created with minimal metadata only.
*/
config?: LeagueConfigFormModel;
}
export interface CreateSeasonForLeagueResultDTO {
seasonId: string;
}
export interface SeasonSummaryDTO {
seasonId: string;
leagueId: string;
name: string;
status: import('../../domain/entities/Season').SeasonStatus;
startDate?: Date;
endDate?: Date;
isPrimary: boolean;
}
export interface ListSeasonsForLeagueQuery {
leagueId: string;
}
export interface ListSeasonsForLeagueResultDTO {
items: SeasonSummaryDTO[];
}
export interface GetSeasonDetailsQuery {
leagueId: string;
seasonId: string;
}
export interface SeasonDetailsDTO {
seasonId: string;
leagueId: string;
gameId: string;
name: string;
status: import('../../domain/entities/Season').SeasonStatus;
startDate?: Date;
endDate?: Date;
maxDrivers?: number;
schedule?: {
startDate: Date;
plannedRounds: number;
};
scoring?: {
scoringPresetId: string;
customScoringEnabled: boolean;
};
dropPolicy?: {
strategy: import('../../domain/value-objects/SeasonDropPolicy').SeasonDropStrategy;
n?: number;
};
stewarding?: {
decisionMode: import('../../domain/entities/League').StewardingDecisionMode;
requiredVotes?: number;
requireDefense: boolean;
defenseTimeLimit: number;
voteTimeLimit: number;
protestDeadlineHours: number;
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
};
}
export type SeasonLifecycleTransition =
| 'activate'
| 'complete'
| 'archive'
| 'cancel';
export interface ManageSeasonLifecycleCommand {
leagueId: string;
seasonId: string;
transition: SeasonLifecycleTransition;
}
export interface ManageSeasonLifecycleResultDTO {
seasonId: string;
status: import('../../domain/entities/Season').SeasonStatus;
startDate?: Date;
endDate?: Date;
}
/**
* CreateSeasonForLeagueUseCase
*
* Creates a new Season for an existing League, optionally cloning or deriving
* configuration from a source Season or a league config form.
*/
export class CreateSeasonForLeagueUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(
command: CreateSeasonForLeagueCommand,
): Promise<CreateSeasonForLeagueResultDTO> {
const league = await this.leagueRepository.findById(command.leagueId);
if (!league) {
throw new Error(`League not found: ${command.leagueId}`);
}
let baseSeasonProps: {
schedule?: SeasonSchedule;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
} = {};
if (command.sourceSeasonId) {
const source = await this.seasonRepository.findById(command.sourceSeasonId);
if (!source) {
throw new Error(`Source Season not found: ${command.sourceSeasonId}`);
}
baseSeasonProps = {
...(source.schedule !== undefined ? { schedule: source.schedule } : {}),
...(source.scoringConfig !== undefined
? { scoringConfig: source.scoringConfig }
: {}),
...(source.dropPolicy !== undefined ? { dropPolicy: source.dropPolicy } : {}),
...(source.stewardingConfig !== undefined
? { stewardingConfig: source.stewardingConfig }
: {}),
...(source.maxDrivers !== undefined ? { maxDrivers: source.maxDrivers } : {}),
};
} else if (command.config) {
baseSeasonProps = this.deriveSeasonPropsFromConfig(command.config);
}
const seasonId = uuidv4();
const season = Season.create({
id: seasonId,
leagueId: league.id,
gameId: command.gameId,
name: command.name,
year: new Date().getFullYear(),
status: 'planned',
...(baseSeasonProps?.schedule
? { schedule: baseSeasonProps.schedule }
: {}),
...(baseSeasonProps?.scoringConfig
? { scoringConfig: baseSeasonProps.scoringConfig }
: {}),
...(baseSeasonProps?.dropPolicy
? { dropPolicy: baseSeasonProps.dropPolicy }
: {}),
...(baseSeasonProps?.stewardingConfig
? { stewardingConfig: baseSeasonProps.stewardingConfig }
: {}),
...(baseSeasonProps?.maxDrivers !== undefined
? { maxDrivers: baseSeasonProps.maxDrivers }
: {}),
});
await this.seasonRepository.add(season);
return { seasonId };
}
private deriveSeasonPropsFromConfig(config: LeagueConfigFormModel): {
schedule?: SeasonSchedule;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
} {
const schedule = this.buildScheduleFromTimings(config);
const scoringConfig = new SeasonScoringConfig({
scoringPresetId: config.scoring.patternId ?? 'custom',
customScoringEnabled: config.scoring.customScoringEnabled ?? false,
});
const dropPolicy = new SeasonDropPolicy({
strategy: config.dropPolicy.strategy,
...(config.dropPolicy.n !== undefined ? { n: config.dropPolicy.n } : {}),
});
const stewardingConfig = new SeasonStewardingConfig({
decisionMode: config.stewarding.decisionMode,
...(config.stewarding.requiredVotes !== undefined
? { requiredVotes: config.stewarding.requiredVotes }
: {}),
requireDefense: config.stewarding.requireDefense,
defenseTimeLimit: config.stewarding.defenseTimeLimit,
voteTimeLimit: config.stewarding.voteTimeLimit,
protestDeadlineHours: config.stewarding.protestDeadlineHours,
stewardingClosesHours: config.stewarding.stewardingClosesHours,
notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest,
notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired,
});
const structure = config.structure;
const maxDrivers =
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
? structure.maxDrivers
: undefined;
return {
...(schedule !== undefined ? { schedule } : {}),
scoringConfig,
dropPolicy,
stewardingConfig,
...(maxDrivers !== undefined ? { maxDrivers } : {}),
};
}
private buildScheduleFromTimings(
config: LeagueConfigFormModel,
): SeasonSchedule | undefined {
const { timings } = config;
if (!timings.seasonStartDate || !timings.raceStartTime) {
return undefined;
}
const startDate = new Date(timings.seasonStartDate);
const timeOfDay = RaceTimeOfDay.fromString(timings.raceStartTime);
const timezoneId = timings.timezoneId ?? 'UTC';
const timezone = new LeagueTimezone(timezoneId);
const plannedRounds =
typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > 0
? timings.roundsPlanned
: timings.sessionCount;
const recurrence = (() => {
const weekdays: WeekdaySet =
timings.weekdays && timings.weekdays.length > 0
? WeekdaySet.fromArray(
timings.weekdays as unknown as Weekday[],
)
: WeekdaySet.fromArray(['Mon']);
switch (timings.recurrenceStrategy) {
case 'everyNWeeks':
return RecurrenceStrategyFactory.everyNWeeks(
timings.intervalWeeks ?? 2,
weekdays,
);
case 'monthlyNthWeekday': {
const pattern = new MonthlyRecurrencePattern({
ordinal: (timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4,
weekday: (timings.monthlyWeekday ?? 'Mon') as Weekday,
});
return RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
}
case 'weekly':
default:
return RecurrenceStrategyFactory.weekly(weekdays);
}
})();
return new SeasonSchedule({
startDate,
timeOfDay,
timezone,
recurrence,
plannedRounds,
});
}
}
/**
* ListSeasonsForLeagueUseCase
*/
export class ListSeasonsForLeagueUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(
query: ListSeasonsForLeagueQuery,
): Promise<ListSeasonsForLeagueResultDTO> {
const league = await this.leagueRepository.findById(query.leagueId);
if (!league) {
throw new Error(`League not found: ${query.leagueId}`);
}
const seasons = await this.seasonRepository.listByLeague(league.id);
const items: SeasonSummaryDTO[] = seasons.map((s) => ({
seasonId: s.id,
leagueId: s.leagueId,
name: s.name,
status: s.status,
...(s.startDate !== undefined ? { startDate: s.startDate } : {}),
...(s.endDate !== undefined ? { endDate: s.endDate } : {}),
// League currently does not track primarySeasonId, so mark false for now.
isPrimary: false,
}));
return { items };
}
}
/**
* GetSeasonDetailsUseCase
*/
export class GetSeasonDetailsUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(query: GetSeasonDetailsQuery): Promise<SeasonDetailsDTO> {
const league = await this.leagueRepository.findById(query.leagueId);
if (!league) {
throw new Error(`League not found: ${query.leagueId}`);
}
const season = await this.seasonRepository.findById(query.seasonId);
if (!season || season.leagueId !== league.id) {
throw new Error(
`Season ${query.seasonId} does not belong to league ${league.id}`,
);
}
return {
seasonId: season.id,
leagueId: season.leagueId,
gameId: season.gameId,
name: season.name,
status: season.status,
...(season.startDate !== undefined ? { startDate: season.startDate } : {}),
...(season.endDate !== undefined ? { endDate: season.endDate } : {}),
...(season.maxDrivers !== undefined ? { maxDrivers: season.maxDrivers } : {}),
...(season.schedule
? {
schedule: {
startDate: season.schedule.startDate,
plannedRounds: season.schedule.plannedRounds,
},
}
: {}),
...(season.scoringConfig
? {
scoring: {
scoringPresetId: season.scoringConfig.scoringPresetId,
customScoringEnabled:
season.scoringConfig.customScoringEnabled ?? false,
},
}
: {}),
...(season.dropPolicy
? {
dropPolicy: {
strategy: season.dropPolicy.strategy,
...(season.dropPolicy.n !== undefined
? { n: season.dropPolicy.n }
: {}),
},
}
: {}),
...(season.stewardingConfig
? {
stewarding: {
decisionMode: season.stewardingConfig.decisionMode,
...(season.stewardingConfig.requiredVotes !== undefined
? { requiredVotes: season.stewardingConfig.requiredVotes }
: {}),
requireDefense: season.stewardingConfig.requireDefense,
defenseTimeLimit: season.stewardingConfig.defenseTimeLimit,
voteTimeLimit: season.stewardingConfig.voteTimeLimit,
protestDeadlineHours:
season.stewardingConfig.protestDeadlineHours,
stewardingClosesHours:
season.stewardingConfig.stewardingClosesHours,
notifyAccusedOnProtest:
season.stewardingConfig.notifyAccusedOnProtest,
notifyOnVoteRequired:
season.stewardingConfig.notifyOnVoteRequired,
},
}
: {}),
};
}
}
/**
* ManageSeasonLifecycleUseCase
*/
export class ManageSeasonLifecycleUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
async execute(
command: ManageSeasonLifecycleCommand,
): Promise<ManageSeasonLifecycleResultDTO> {
const league = await this.leagueRepository.findById(command.leagueId);
if (!league) {
throw new Error(`League not found: ${command.leagueId}`);
}
const season = await this.seasonRepository.findById(command.seasonId);
if (!season || season.leagueId !== league.id) {
throw new Error(
`Season ${command.seasonId} does not belong to league ${league.id}`,
);
}
let updated: Season;
switch (command.transition) {
case 'activate':
updated = season.activate();
break;
case 'complete':
updated = season.complete();
break;
case 'archive':
updated = season.archive();
break;
case 'cancel':
updated = season.cancel();
break;
default:
throw new Error(`Unsupported Season lifecycle transition`);
}
await this.seasonRepository.update(updated);
return {
seasonId: updated.id,
status: updated.status,
...(updated.startDate !== undefined ? { startDate: updated.startDate } : {}),
...(updated.endDate !== undefined ? { endDate: updated.endDate } : {}),
};
}
}

View File

@@ -26,8 +26,8 @@ export interface DriverLiveryProps {
userDecals: LiveryDecal[];
leagueOverrides: DecalOverride[];
createdAt: Date;
updatedAt?: Date;
validatedAt?: Date;
updatedAt: Date | undefined;
validatedAt: Date | undefined;
}
export class DriverLivery implements IEntity<string> {
@@ -39,8 +39,8 @@ export class DriverLivery implements IEntity<string> {
readonly userDecals: LiveryDecal[];
readonly leagueOverrides: DecalOverride[];
readonly createdAt: Date;
readonly updatedAt?: Date;
readonly validatedAt?: Date;
readonly updatedAt: Date | undefined;
readonly validatedAt: Date | undefined;
private constructor(props: DriverLiveryProps) {
this.id = props.id;
@@ -50,7 +50,7 @@ export class DriverLivery implements IEntity<string> {
this.uploadedImageUrl = props.uploadedImageUrl;
this.userDecals = props.userDecals;
this.leagueOverrides = props.leagueOverrides;
this.createdAt = props.createdAt;
this.createdAt = props.createdAt ?? new Date();
this.updatedAt = props.updatedAt;
this.validatedAt = props.validatedAt;
}
@@ -101,9 +101,16 @@ export class DriverLivery implements IEntity<string> {
}
return new DriverLivery({
...this,
id: this.id,
driverId: this.driverId,
gameId: this.gameId,
carId: this.carId,
uploadedImageUrl: this.uploadedImageUrl,
userDecals: [...this.userDecals, decal],
leagueOverrides: this.leagueOverrides,
createdAt: this.createdAt,
updatedAt: new Date(),
validatedAt: this.validatedAt,
});
}
@@ -118,9 +125,16 @@ export class DriverLivery implements IEntity<string> {
}
return new DriverLivery({
...this,
id: this.id,
driverId: this.driverId,
gameId: this.gameId,
carId: this.carId,
uploadedImageUrl: this.uploadedImageUrl,
userDecals: updatedDecals,
leagueOverrides: this.leagueOverrides,
createdAt: this.createdAt,
updatedAt: new Date(),
validatedAt: this.validatedAt,
});
}
@@ -131,16 +145,23 @@ export class DriverLivery implements IEntity<string> {
const index = this.userDecals.findIndex(d => d.id === decalId);
if (index === -1) {
throw new RacingDomainError('Decal not found in livery');
throw new RacingDomainValidationError('Decal not found in livery');
}
const updatedDecals = [...this.userDecals];
updatedDecals[index] = updatedDecal;
return new DriverLivery({
...this,
id: this.id,
driverId: this.driverId,
gameId: this.gameId,
carId: this.carId,
uploadedImageUrl: this.uploadedImageUrl,
userDecals: updatedDecals,
leagueOverrides: this.leagueOverrides,
createdAt: this.createdAt,
updatedAt: new Date(),
validatedAt: this.validatedAt,
});
}
@@ -163,9 +184,16 @@ export class DriverLivery implements IEntity<string> {
}
return new DriverLivery({
...this,
id: this.id,
driverId: this.driverId,
gameId: this.gameId,
carId: this.carId,
uploadedImageUrl: this.uploadedImageUrl,
userDecals: this.userDecals,
leagueOverrides: updatedOverrides,
createdAt: this.createdAt,
updatedAt: new Date(),
validatedAt: this.validatedAt,
});
}
@@ -178,9 +206,16 @@ export class DriverLivery implements IEntity<string> {
);
return new DriverLivery({
...this,
id: this.id,
driverId: this.driverId,
gameId: this.gameId,
carId: this.carId,
uploadedImageUrl: this.uploadedImageUrl,
userDecals: this.userDecals,
leagueOverrides: updatedOverrides,
createdAt: this.createdAt,
updatedAt: new Date(),
validatedAt: this.validatedAt,
});
}
@@ -198,7 +233,15 @@ export class DriverLivery implements IEntity<string> {
*/
markAsValidated(): DriverLivery {
return new DriverLivery({
...this,
id: this.id,
driverId: this.driverId,
gameId: this.gameId,
carId: this.carId,
uploadedImageUrl: this.uploadedImageUrl,
userDecals: this.userDecals,
leagueOverrides: this.leagueOverrides,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
validatedAt: new Date(),
});
}

View File

@@ -17,7 +17,7 @@ export interface LiveryTemplateProps {
baseImageUrl: string;
adminDecals: LiveryDecal[];
createdAt: Date;
updatedAt?: Date;
updatedAt: Date | undefined;
}
export class LiveryTemplate implements IEntity<string> {
@@ -28,7 +28,7 @@ export class LiveryTemplate implements IEntity<string> {
readonly baseImageUrl: string;
readonly adminDecals: LiveryDecal[];
readonly createdAt: Date;
readonly updatedAt?: Date;
readonly updatedAt: Date | undefined;
private constructor(props: LiveryTemplateProps) {
this.id = props.id;
@@ -37,7 +37,7 @@ export class LiveryTemplate implements IEntity<string> {
this.carId = props.carId;
this.baseImageUrl = props.baseImageUrl;
this.adminDecals = props.adminDecals;
this.createdAt = props.createdAt;
this.createdAt = props.createdAt ?? new Date();
this.updatedAt = props.updatedAt;
}
@@ -113,9 +113,9 @@ export class LiveryTemplate implements IEntity<string> {
*/
updateDecal(decalId: string, updatedDecal: LiveryDecal): LiveryTemplate {
const index = this.adminDecals.findIndex(d => d.id === decalId);
if (index === -1) {
throw new RacingDomainError('Decal not found in template');
throw new RacingDomainValidationError('Decal not found in template');
}
const updatedDecals = [...this.adminDecals];

View File

@@ -19,9 +19,9 @@ export interface PrizeProps {
driverId?: string;
status: PrizeStatus;
createdAt: Date;
awardedAt?: Date;
paidAt?: Date;
description?: string;
awardedAt: Date | undefined;
paidAt: Date | undefined;
description: string | undefined;
}
export class Prize implements IEntity<string> {
@@ -29,12 +29,12 @@ export class Prize implements IEntity<string> {
readonly seasonId: string;
readonly position: number;
readonly amount: Money;
readonly driverId?: string;
readonly driverId: string | undefined;
readonly status: PrizeStatus;
readonly createdAt: Date;
readonly awardedAt?: Date;
readonly paidAt?: Date;
readonly description?: string;
readonly awardedAt: Date | undefined;
readonly paidAt: Date | undefined;
readonly description: string | undefined;
private constructor(props: PrizeProps) {
this.id = props.id;
@@ -43,7 +43,7 @@ export class Prize implements IEntity<string> {
this.amount = props.amount;
this.driverId = props.driverId;
this.status = props.status;
this.createdAt = props.createdAt;
this.createdAt = props.createdAt ?? new Date();
this.awardedAt = props.awardedAt;
this.paidAt = props.paidAt;
this.description = props.description;

View File

@@ -1,8 +1,20 @@
export type SeasonStatus = 'planned' | 'active' | 'completed';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type SeasonStatus =
| 'planned'
| 'active'
| 'completed'
| 'archived'
| 'cancelled';
import {
RacingDomainInvariantError,
RacingDomainValidationError,
} from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { SeasonSchedule } from '../value-objects/SeasonSchedule';
import type { SeasonScoringConfig } from '../value-objects/SeasonScoringConfig';
import type { SeasonDropPolicy } from '../value-objects/SeasonDropPolicy';
import type { SeasonStewardingConfig } from '../value-objects/SeasonStewardingConfig';
export class Season implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
@@ -13,6 +25,11 @@ export class Season implements IEntity<string> {
readonly status: SeasonStatus;
readonly startDate: Date | undefined;
readonly endDate: Date | undefined;
readonly schedule: SeasonSchedule | undefined;
readonly scoringConfig: SeasonScoringConfig | undefined;
readonly dropPolicy: SeasonDropPolicy | undefined;
readonly stewardingConfig: SeasonStewardingConfig | undefined;
readonly maxDrivers: number | undefined;
private constructor(props: {
id: string;
@@ -24,6 +41,11 @@ export class Season implements IEntity<string> {
status: SeasonStatus;
startDate?: Date;
endDate?: Date;
schedule?: SeasonSchedule;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
}) {
this.id = props.id;
this.leagueId = props.leagueId;
@@ -34,6 +56,11 @@ export class Season implements IEntity<string> {
this.status = props.status;
this.startDate = props.startDate;
this.endDate = props.endDate;
this.schedule = props.schedule;
this.scoringConfig = props.scoringConfig;
this.dropPolicy = props.dropPolicy;
this.stewardingConfig = props.stewardingConfig;
this.maxDrivers = props.maxDrivers;
}
static create(props: {
@@ -46,6 +73,11 @@ export class Season implements IEntity<string> {
status?: SeasonStatus;
startDate?: Date;
endDate?: Date;
schedule?: SeasonSchedule;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
}): Season {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Season ID is required');
@@ -75,6 +107,15 @@ export class Season implements IEntity<string> {
status,
...(props.startDate !== undefined ? { startDate: props.startDate } : {}),
...(props.endDate !== undefined ? { endDate: props.endDate } : {}),
...(props.schedule !== undefined ? { schedule: props.schedule } : {}),
...(props.scoringConfig !== undefined
? { scoringConfig: props.scoringConfig }
: {}),
...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}),
...(props.stewardingConfig !== undefined
? { stewardingConfig: props.stewardingConfig }
: {}),
...(props.maxDrivers !== undefined ? { maxDrivers: props.maxDrivers } : {}),
});
}
@@ -98,4 +139,281 @@ export class Season implements IEntity<string> {
isCompleted(): boolean {
return this.status === 'completed';
}
/**
* Check if season is planned (not yet active)
*/
isPlanned(): boolean {
return this.status === 'planned';
}
/**
* Activate the season from planned state.
*/
activate(): Season {
if (this.status !== 'planned') {
throw new RacingDomainInvariantError(
'Only planned seasons can be activated',
);
}
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: 'active',
...(this.startDate !== undefined ? { startDate: this.startDate } : {
startDate: new Date(),
}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Mark the season as completed.
*/
complete(): Season {
if (this.status !== 'active') {
throw new RacingDomainInvariantError(
'Only active seasons can be completed',
);
}
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: 'completed',
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {
endDate: new Date(),
}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Archive a completed season.
*/
archive(): Season {
if (!this.isCompleted()) {
throw new RacingDomainInvariantError(
'Only completed seasons can be archived',
);
}
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: 'archived',
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Cancel a planned or active season.
*/
cancel(): Season {
if (this.status === 'completed' || this.status === 'archived') {
throw new RacingDomainInvariantError(
'Cannot cancel a completed or archived season',
);
}
if (this.status === 'cancelled') {
return this;
}
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: 'cancelled',
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {
endDate: new Date(),
}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Update schedule while keeping other properties intact.
*/
withSchedule(schedule: SeasonSchedule): Season {
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: this.status,
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
schedule,
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Update scoring configuration for the season.
*/
withScoringConfig(scoringConfig: SeasonScoringConfig): Season {
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: this.status,
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
scoringConfig,
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Update drop policy for the season.
*/
withDropPolicy(dropPolicy: SeasonDropPolicy): Season {
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: this.status,
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
dropPolicy,
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Update stewarding configuration for the season.
*/
withStewardingConfig(stewardingConfig: SeasonStewardingConfig): Season {
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: this.status,
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
stewardingConfig,
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
});
}
/**
* Update max driver capacity for the season.
*/
withMaxDrivers(maxDrivers: number | undefined): Season {
if (maxDrivers !== undefined && maxDrivers <= 0) {
throw new RacingDomainValidationError(
'Season maxDrivers must be greater than 0 when provided',
);
}
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: this.status,
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined
? { scoringConfig: this.scoringConfig }
: {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined
? { stewardingConfig: this.stewardingConfig }
: {}),
...(maxDrivers !== undefined ? { maxDrivers } : {}),
});
}
}

View File

@@ -11,40 +11,53 @@ import type { IEntity } from '@gridpilot/shared/domain';
import type { Money } from '../value-objects/Money';
export type SponsorshipTier = 'main' | 'secondary';
export type SponsorshipStatus = 'pending' | 'active' | 'cancelled';
export type SponsorshipStatus = 'pending' | 'active' | 'ended' | 'cancelled';
export interface SeasonSponsorshipProps {
id: string;
seasonId: string;
/**
* Optional denormalized leagueId for fast league-level aggregations.
* Must always match the owning Season's leagueId when present.
*/
leagueId?: string;
sponsorId: string;
tier: SponsorshipTier;
pricing: Money;
status: SponsorshipStatus;
createdAt: Date;
activatedAt?: Date;
endedAt?: Date;
cancelledAt?: Date;
description?: string;
}
export class SeasonSponsorship implements IEntity<string> {
readonly id: string;
readonly seasonId: string;
readonly leagueId: string | undefined;
readonly sponsorId: string;
readonly tier: SponsorshipTier;
readonly pricing: Money;
readonly status: SponsorshipStatus;
readonly createdAt: Date;
readonly activatedAt: Date | undefined;
readonly endedAt: Date | undefined;
readonly cancelledAt: Date | undefined;
readonly description: string | undefined;
private constructor(props: SeasonSponsorshipProps) {
this.id = props.id;
this.seasonId = props.seasonId;
this.leagueId = props.leagueId;
this.sponsorId = props.sponsorId;
this.tier = props.tier;
this.pricing = props.pricing;
this.status = props.status;
this.createdAt = props.createdAt;
this.activatedAt = props.activatedAt;
this.endedAt = props.endedAt;
this.cancelledAt = props.cancelledAt;
this.description = props.description;
}
@@ -57,12 +70,15 @@ export class SeasonSponsorship implements IEntity<string> {
return new SeasonSponsorship({
id: props.id,
seasonId: props.seasonId,
...(props.leagueId !== undefined ? { leagueId: props.leagueId } : {}),
sponsorId: props.sponsorId,
tier: props.tier,
pricing: props.pricing,
status: props.status ?? 'pending',
createdAt: props.createdAt ?? new Date(),
...(props.activatedAt !== undefined ? { activatedAt: props.activatedAt } : {}),
...(props.endedAt !== undefined ? { endedAt: props.endedAt } : {}),
...(props.cancelledAt !== undefined ? { cancelledAt: props.cancelledAt } : {}),
...(props.description !== undefined ? { description: props.description } : {}),
});
}
@@ -105,15 +121,56 @@ export class SeasonSponsorship implements IEntity<string> {
throw new RacingDomainInvariantError('Cannot activate a cancelled SeasonSponsorship');
}
if (this.status === 'ended') {
throw new RacingDomainInvariantError('Cannot activate an ended SeasonSponsorship');
}
const base: SeasonSponsorshipProps = {
id: this.id,
seasonId: this.seasonId,
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
sponsorId: this.sponsorId,
tier: this.tier,
pricing: this.pricing,
status: 'active',
createdAt: this.createdAt,
activatedAt: new Date(),
...(this.endedAt !== undefined ? { endedAt: this.endedAt } : {}),
...(this.cancelledAt !== undefined ? { cancelledAt: this.cancelledAt } : {}),
};
const next: SeasonSponsorshipProps =
this.description !== undefined
? { ...base, description: this.description }
: base;
return new SeasonSponsorship(next);
}
/**
* Mark the sponsorship as ended (completed term)
*/
end(): SeasonSponsorship {
if (this.status === 'cancelled') {
throw new RacingDomainInvariantError('Cannot end a cancelled SeasonSponsorship');
}
if (this.status === 'ended') {
throw new RacingDomainInvariantError('SeasonSponsorship is already ended');
}
const base: SeasonSponsorshipProps = {
id: this.id,
seasonId: this.seasonId,
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
sponsorId: this.sponsorId,
tier: this.tier,
pricing: this.pricing,
status: 'ended',
createdAt: this.createdAt,
...(this.activatedAt !== undefined ? { activatedAt: this.activatedAt } : {}),
endedAt: new Date(),
...(this.cancelledAt !== undefined ? { cancelledAt: this.cancelledAt } : {}),
};
const next: SeasonSponsorshipProps =
@@ -135,22 +192,55 @@ export class SeasonSponsorship implements IEntity<string> {
const base: SeasonSponsorshipProps = {
id: this.id,
seasonId: this.seasonId,
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
sponsorId: this.sponsorId,
tier: this.tier,
pricing: this.pricing,
status: 'cancelled',
createdAt: this.createdAt,
...(this.activatedAt !== undefined ? { activatedAt: this.activatedAt } : {}),
...(this.endedAt !== undefined ? { endedAt: this.endedAt } : {}),
cancelledAt: new Date(),
};
const withActivated =
this.activatedAt !== undefined
? { ...base, activatedAt: this.activatedAt }
: base;
const next: SeasonSponsorshipProps =
this.description !== undefined
? { ...withActivated, description: this.description }
: withActivated;
? { ...base, description: this.description }
: base;
return new SeasonSponsorship(next);
}
/**
* Update pricing/terms when allowed
*/
withPricing(pricing: Money): SeasonSponsorship {
if (pricing.amount <= 0) {
throw new RacingDomainValidationError('SeasonSponsorship pricing must be greater than zero');
}
if (this.status === 'cancelled' || this.status === 'ended') {
throw new RacingDomainInvariantError('Cannot update pricing for ended or cancelled SeasonSponsorship');
}
const base: SeasonSponsorshipProps = {
id: this.id,
seasonId: this.seasonId,
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
sponsorId: this.sponsorId,
tier: this.tier,
pricing,
status: this.status,
createdAt: this.createdAt,
...(this.activatedAt !== undefined ? { activatedAt: this.activatedAt } : {}),
...(this.endedAt !== undefined ? { endedAt: this.endedAt } : {}),
...(this.cancelledAt !== undefined ? { cancelledAt: this.cancelledAt } : {}),
};
const next: SeasonSponsorshipProps =
this.description !== undefined
? { ...base, description: this.description }
: base;
return new SeasonSponsorship(next);
}

View File

@@ -27,9 +27,9 @@ export interface TransactionProps {
netAmount: Money;
status: TransactionStatus;
createdAt: Date;
completedAt?: Date;
description?: string;
metadata?: Record<string, unknown>;
completedAt: Date | undefined;
description: string | undefined;
metadata: Record<string, unknown> | undefined;
}
export class Transaction implements IEntity<string> {
@@ -41,9 +41,9 @@ export class Transaction implements IEntity<string> {
readonly netAmount: Money;
readonly status: TransactionStatus;
readonly createdAt: Date;
readonly completedAt?: Date;
readonly description?: string;
readonly metadata?: Record<string, unknown>;
readonly completedAt: Date | undefined;
readonly description: string | undefined;
readonly metadata: Record<string, unknown> | undefined;
private constructor(props: TransactionProps) {
this.id = props.id;

View File

@@ -2,6 +2,34 @@ import type { Season } from '../entities/Season';
export interface ISeasonRepository {
findById(id: string): Promise<Season | null>;
/**
* Backward-compatible alias retained for existing callers.
* Prefer listByLeague for new usage.
*/
findByLeagueId(leagueId: string): Promise<Season[]>;
/**
* Backward-compatible alias retained for existing callers.
* Prefer add for new usage.
*/
create(season: Season): Promise<Season>;
/**
* Add a new Season aggregate.
*/
add(season: Season): Promise<void>;
/**
* Persist changes to an existing Season aggregate.
*/
update(season: Season): Promise<void>;
/**
* List all Seasons for a given League.
*/
listByLeague(leagueId: string): Promise<Season[]>;
/**
* List Seasons for a League that are currently active.
*/
listActiveByLeague(leagueId: string): Promise<Season[]>;
}

View File

@@ -9,6 +9,12 @@ import type { SeasonSponsorship, SponsorshipTier } from '../entities/SeasonSpons
export interface ISeasonSponsorshipRepository {
findById(id: string): Promise<SeasonSponsorship | null>;
findBySeasonId(seasonId: string): Promise<SeasonSponsorship[]>;
/**
* Convenience lookup for aggregating sponsorships at league level.
* Implementations should rely on the denormalized leagueId where present,
* falling back to joining through Seasons if needed.
*/
findByLeagueId(leagueId: string): Promise<SeasonSponsorship[]>;
findBySponsorId(sponsorId: string): Promise<SeasonSponsorship[]>;
findBySeasonAndTier(seasonId: string, tier: SponsorshipTier): Promise<SeasonSponsorship[]>;
create(sponsorship: SeasonSponsorship): Promise<SeasonSponsorship>;

View File

@@ -9,10 +9,20 @@ export interface MonthlyRecurrencePatternProps {
export class MonthlyRecurrencePattern implements IValueObject<MonthlyRecurrencePatternProps> {
readonly ordinal: 1 | 2 | 3 | 4;
readonly weekday: Weekday;
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday) {
this.ordinal = ordinal;
this.weekday = weekday;
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday);
constructor(props: MonthlyRecurrencePatternProps);
constructor(
ordinalOrProps: 1 | 2 | 3 | 4 | MonthlyRecurrencePatternProps,
weekday?: Weekday,
) {
if (typeof ordinalOrProps === 'object') {
this.ordinal = ordinalOrProps.ordinal;
this.weekday = ordinalOrProps.weekday;
} else {
this.ordinal = ordinalOrProps;
this.weekday = weekday as Weekday;
}
}
get props(): MonthlyRecurrencePatternProps {

View File

@@ -0,0 +1,59 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type SeasonDropStrategy = 'none' | 'bestNResults' | 'dropWorstN';
export interface SeasonDropPolicyProps {
strategy: SeasonDropStrategy;
/**
* Number of results to consider for strategies that require a count.
* - bestNResults: keep best N
* - dropWorstN: drop worst N
*/
n?: number;
}
export class SeasonDropPolicy implements IValueObject<SeasonDropPolicyProps> {
readonly strategy: SeasonDropStrategy;
readonly n?: number;
constructor(props: SeasonDropPolicyProps) {
if (!props.strategy) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.strategy is required',
);
}
if (props.strategy === 'bestNResults' || props.strategy === 'dropWorstN') {
if (props.n === undefined || !Number.isInteger(props.n) || props.n <= 0) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.n must be a positive integer when using bestNResults or dropWorstN',
);
}
}
if (props.strategy === 'none' && props.n !== undefined) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.n must be undefined when strategy is none',
);
}
this.strategy = props.strategy;
if (props.n !== undefined) {
this.n = props.n;
}
}
get props(): SeasonDropPolicyProps {
return {
strategy: this.strategy,
...(this.n !== undefined ? { n: this.n } : {}),
};
}
equals(other: IValueObject<SeasonDropPolicyProps>): boolean {
const a = this.props;
const b = other.props;
return a.strategy === b.strategy && a.n === b.n;
}
}

View File

@@ -0,0 +1,66 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
/**
* Value Object: SeasonScoringConfig
*
* Represents the scoring configuration owned by a Season.
* It is intentionally lightweight and primarily captures which
* preset (or custom mode) is applied for this Season.
*
* Detailed championship scoring rules are still modeled via
* `LeagueScoringConfig` and related types.
*/
export interface SeasonScoringConfigProps {
/**
* Identifier of the scoring preset applied to this Season.
* Examples:
* - 'sprint-main-driver'
* - 'club-default'
* - 'endurance-main-double'
* - 'custom'
*/
scoringPresetId: string;
/**
* Whether the Season uses custom scoring rather than a pure preset.
* When true, `scoringPresetId` acts as a label rather than a strict preset key.
*/
customScoringEnabled?: boolean;
}
export class SeasonScoringConfig
implements IValueObject<SeasonScoringConfigProps>
{
readonly scoringPresetId: string;
readonly customScoringEnabled: boolean;
constructor(params: SeasonScoringConfigProps) {
if (!params.scoringPresetId || params.scoringPresetId.trim().length === 0) {
throw new RacingDomainValidationError(
'SeasonScoringConfig.scoringPresetId must be a non-empty string',
);
}
this.scoringPresetId = params.scoringPresetId.trim();
this.customScoringEnabled = Boolean(params.customScoringEnabled);
}
get props(): SeasonScoringConfigProps {
return {
scoringPresetId: this.scoringPresetId,
...(this.customScoringEnabled
? { customScoringEnabled: this.customScoringEnabled }
: {}),
};
}
equals(other: IValueObject<SeasonScoringConfigProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.scoringPresetId === b.scoringPresetId &&
Boolean(a.customScoringEnabled) === Boolean(b.customScoringEnabled)
);
}
}

View File

@@ -0,0 +1,131 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
import type { StewardingDecisionMode } from '../entities/League';
export interface SeasonStewardingConfigProps {
decisionMode: StewardingDecisionMode;
requiredVotes?: number;
requireDefense: boolean;
defenseTimeLimit: number;
voteTimeLimit: number;
protestDeadlineHours: number;
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
}
/**
* Value Object: SeasonStewardingConfig
*
* Encapsulates stewarding configuration owned by a Season.
* Shape intentionally mirrors LeagueStewardingFormDTO used by the wizard.
*/
export class SeasonStewardingConfig
implements IValueObject<SeasonStewardingConfigProps>
{
readonly decisionMode: StewardingDecisionMode;
readonly requiredVotes?: number;
readonly requireDefense: boolean;
readonly defenseTimeLimit: number;
readonly voteTimeLimit: number;
readonly protestDeadlineHours: number;
readonly stewardingClosesHours: number;
readonly notifyAccusedOnProtest: boolean;
readonly notifyOnVoteRequired: boolean;
constructor(props: SeasonStewardingConfigProps) {
if (!props.decisionMode) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.decisionMode is required',
);
}
if (
(props.decisionMode === 'steward_vote' ||
props.decisionMode === 'member_vote' ||
props.decisionMode === 'steward_veto' ||
props.decisionMode === 'member_veto') &&
(props.requiredVotes === undefined ||
!Number.isInteger(props.requiredVotes) ||
props.requiredVotes <= 0)
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.requiredVotes must be a positive integer for voting/veto modes',
);
}
if (!Number.isInteger(props.defenseTimeLimit) || props.defenseTimeLimit < 0) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.defenseTimeLimit must be a non-negative integer (hours)',
);
}
if (!Number.isInteger(props.voteTimeLimit) || props.voteTimeLimit <= 0) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.voteTimeLimit must be a positive integer (hours)',
);
}
if (
!Number.isInteger(props.protestDeadlineHours) ||
props.protestDeadlineHours <= 0
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.protestDeadlineHours must be a positive integer (hours)',
);
}
if (
!Number.isInteger(props.stewardingClosesHours) ||
props.stewardingClosesHours <= 0
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.stewardingClosesHours must be a positive integer (hours)',
);
}
this.decisionMode = props.decisionMode;
if (props.requiredVotes !== undefined) {
this.requiredVotes = props.requiredVotes;
}
this.requireDefense = props.requireDefense;
this.defenseTimeLimit = props.defenseTimeLimit;
this.voteTimeLimit = props.voteTimeLimit;
this.protestDeadlineHours = props.protestDeadlineHours;
this.stewardingClosesHours = props.stewardingClosesHours;
this.notifyAccusedOnProtest = props.notifyAccusedOnProtest;
this.notifyOnVoteRequired = props.notifyOnVoteRequired;
}
get props(): SeasonStewardingConfigProps {
return {
decisionMode: this.decisionMode,
...(this.requiredVotes !== undefined
? { requiredVotes: this.requiredVotes }
: {}),
requireDefense: this.requireDefense,
defenseTimeLimit: this.defenseTimeLimit,
voteTimeLimit: this.voteTimeLimit,
protestDeadlineHours: this.protestDeadlineHours,
stewardingClosesHours: this.stewardingClosesHours,
notifyAccusedOnProtest: this.notifyAccusedOnProtest,
notifyOnVoteRequired: this.notifyOnVoteRequired,
};
}
equals(other: IValueObject<SeasonStewardingConfigProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.decisionMode === b.decisionMode &&
a.requiredVotes === b.requiredVotes &&
a.requireDefense === b.requireDefense &&
a.defenseTimeLimit === b.defenseTimeLimit &&
a.voteTimeLimit === b.voteTimeLimit &&
a.protestDeadlineHours === b.protestDeadlineHours &&
a.stewardingClosesHours === b.stewardingClosesHours &&
a.notifyAccusedOnProtest === b.notifyAccusedOnProtest &&
a.notifyOnVoteRequired === b.notifyOnVoteRequired
);
}
}

View File

@@ -10,6 +10,10 @@ export interface WeekdaySetProps {
export class WeekdaySet implements IValueObject<WeekdaySetProps> {
private readonly days: Weekday[];
static fromArray(days: Weekday[]): WeekdaySet {
return new WeekdaySet(days);
}
constructor(days: Weekday[]) {
if (!Array.isArray(days) || days.length === 0) {
throw new RacingDomainValidationError('WeekdaySet requires at least one weekday');

View File

@@ -282,10 +282,34 @@ export class InMemorySeasonRepository implements ISeasonRepository {
}
async create(season: Season): Promise<Season> {
// Backward-compatible alias for add()
this.seasons.push(season);
return season;
}
async add(season: Season): Promise<void> {
this.seasons.push(season);
}
async update(season: Season): Promise<void> {
const index = this.seasons.findIndex((s) => s.id === season.id);
if (index === -1) {
this.seasons.push(season);
return;
}
this.seasons[index] = season;
}
async listByLeague(leagueId: string): Promise<Season[]> {
return this.seasons.filter((s) => s.leagueId === leagueId);
}
async listActiveByLeague(leagueId: string): Promise<Season[]> {
return this.seasons.filter(
(s) => s.leagueId === leagueId && s.status === 'active',
);
}
seed(season: Season): void {
this.seasons.push(season);
}

View File

@@ -18,6 +18,10 @@ export class InMemorySeasonSponsorshipRepository implements ISeasonSponsorshipRe
return Array.from(this.sponsorships.values()).filter(s => s.seasonId === seasonId);
}
async findByLeagueId(leagueId: string): Promise<SeasonSponsorship[]> {
return Array.from(this.sponsorships.values()).filter(s => s.leagueId === leagueId);
}
async findBySponsorId(sponsorId: string): Promise<SeasonSponsorship[]> {
return Array.from(this.sponsorships.values()).filter(s => s.sponsorId === sponsorId);
}

View File

@@ -35,8 +35,6 @@ export class GetCurrentUserSocialUseCase
avatarUrl: '',
isOnline: false,
lastSeen: new Date(),
primaryLeagueId: undefined,
primaryTeamId: undefined,
}));
const currentUser: CurrentUserSocialDTO = {
@@ -44,8 +42,6 @@ export class GetCurrentUserSocialUseCase
displayName: '',
avatarUrl: '',
countryCode: '',
primaryTeamId: undefined,
primaryLeagueId: undefined,
};
const viewModel: CurrentUserSocialViewModel = {

View File

@@ -33,22 +33,27 @@ export class GetUserFeedUseCase
}
function mapFeedItemToDTO(item: FeedItem): FeedItemDTO {
return {
const mappedType = (item.type as string).replace(/-/g, '_') as FeedItemDTO['type'];
const dto: FeedItemDTO = {
id: item.id,
timestamp:
item.timestamp instanceof Date
? item.timestamp.toISOString()
: new Date(item.timestamp).toISOString(),
type: item.type,
actorFriendId: item.actorFriendId,
actorDriverId: item.actorDriverId,
leagueId: item.leagueId,
raceId: item.raceId,
teamId: item.teamId,
position: item.position,
type: mappedType,
headline: item.headline,
body: item.body,
ctaLabel: item.ctaLabel,
ctaHref: item.ctaHref,
};
if (item.actorFriendId !== undefined) dto.actorFriendId = item.actorFriendId;
if (item.actorDriverId !== undefined) dto.actorDriverId = item.actorDriverId;
if (item.leagueId !== undefined) dto.leagueId = item.leagueId;
if (item.raceId !== undefined) dto.raceId = item.raceId;
if (item.teamId !== undefined) dto.teamId = item.teamId;
if (item.position !== undefined) dto.position = item.position;
if (item.body !== undefined) dto.body = item.body;
if (item.ctaLabel !== undefined) dto.ctaLabel = item.ctaLabel;
if (item.ctaHref !== undefined) dto.ctaHref = item.ctaHref;
return dto;
}

View File

@@ -0,0 +1,449 @@
import { describe, it, expect } from 'vitest';
import {
InMemorySeasonRepository,
} from '@gridpilot/racing/infrastructure/repositories/InMemoryScoringRepositories';
import { Season } from '@gridpilot/racing/domain/entities/Season';
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
import {
CreateSeasonForLeagueUseCase,
ListSeasonsForLeagueUseCase,
GetSeasonDetailsUseCase,
ManageSeasonLifecycleUseCase,
type CreateSeasonForLeagueCommand,
type ManageSeasonLifecycleCommand,
} from '@gridpilot/racing/application/use-cases/SeasonUseCases';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application/dto/LeagueConfigFormDTO';
function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository {
return {
findById: async (id: string) => seed.find((l) => l.id === id) ?? null,
findAll: async () => seed,
create: async (league: any) => league,
update: async (league: any) => league,
} as unknown as ILeagueRepository;
}
function createLeagueConfigFormModel(overrides?: Partial<LeagueConfigFormModel>): LeagueConfigFormModel {
return {
basics: {
name: 'Test League',
visibility: 'ranked',
gameId: 'iracing',
...overrides?.basics,
},
structure: {
mode: 'solo',
maxDrivers: 30,
...overrides?.structure,
},
championships: {
enableDriverChampionship: true,
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
...overrides?.championships,
},
scoring: {
patternId: 'sprint-main-driver',
customScoringEnabled: false,
...overrides?.scoring,
},
dropPolicy: {
strategy: 'bestNResults',
n: 3,
...overrides?.dropPolicy,
},
timings: {
qualifyingMinutes: 10,
mainRaceMinutes: 30,
sessionCount: 8,
seasonStartDate: '2025-01-01',
raceStartTime: '20:00',
timezoneId: 'UTC',
recurrenceStrategy: 'weekly',
weekdays: ['Mon'],
...overrides?.timings,
},
stewarding: {
decisionMode: 'steward_vote',
requiredVotes: 3,
requireDefense: true,
defenseTimeLimit: 24,
voteTimeLimit: 24,
protestDeadlineHours: 48,
stewardingClosesHours: 72,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
...overrides?.stewarding,
},
...overrides,
};
}
describe('InMemorySeasonRepository', () => {
it('add and findById provide a roundtrip for Season', async () => {
const repo = new InMemorySeasonRepository();
const season = Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Test Season',
status: 'planned',
});
await repo.add(season);
const loaded = await repo.findById(season.id);
expect(loaded).not.toBeNull();
expect(loaded!.id).toBe(season.id);
expect(loaded!.leagueId).toBe(season.leagueId);
expect(loaded!.status).toBe('planned');
});
it('update persists changed Season state', async () => {
const repo = new InMemorySeasonRepository();
const season = Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Initial Season',
status: 'planned',
});
await repo.add(season);
const activated = season.activate();
await repo.update(activated);
const loaded = await repo.findById(season.id);
expect(loaded).not.toBeNull();
expect(loaded!.status).toBe('active');
});
it('listByLeague returns only seasons for that league', async () => {
const repo = new InMemorySeasonRepository();
const s1 = Season.create({
id: 's1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'L1 S1',
status: 'planned',
});
const s2 = Season.create({
id: 's2',
leagueId: 'league-1',
gameId: 'iracing',
name: 'L1 S2',
status: 'active',
});
const s3 = Season.create({
id: 's3',
leagueId: 'league-2',
gameId: 'iracing',
name: 'L2 S1',
status: 'planned',
});
await repo.add(s1);
await repo.add(s2);
await repo.add(s3);
const league1Seasons = await repo.listByLeague('league-1');
const league2Seasons = await repo.listByLeague('league-2');
expect(league1Seasons.map((s) => s.id).sort()).toEqual(['s1', 's2']);
expect(league2Seasons.map((s) => s.id)).toEqual(['s3']);
});
it('listActiveByLeague returns only active seasons for a league', async () => {
const repo = new InMemorySeasonRepository();
const s1 = Season.create({
id: 's1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Planned',
status: 'planned',
});
const s2 = Season.create({
id: 's2',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Active',
status: 'active',
});
const s3 = Season.create({
id: 's3',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Completed',
status: 'completed',
});
const s4 = Season.create({
id: 's4',
leagueId: 'league-2',
gameId: 'iracing',
name: 'Other League Active',
status: 'active',
});
await repo.add(s1);
await repo.add(s2);
await repo.add(s3);
await repo.add(s4);
const activeInLeague1 = await repo.listActiveByLeague('league-1');
expect(activeInLeague1.map((s) => s.id)).toEqual(['s2']);
});
});
describe('CreateSeasonForLeagueUseCase', () => {
it('creates a planned Season for an existing league with config-derived props', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
const config = createLeagueConfigFormModel({
basics: {
name: 'League With Config',
visibility: 'ranked',
gameId: 'iracing',
},
scoring: {
patternId: 'club-default',
customScoringEnabled: true,
},
dropPolicy: {
strategy: 'dropWorstN',
n: 2,
},
// Intentionally omit seasonStartDate / raceStartTime to avoid schedule derivation,
// focusing this test on scoring/drop/stewarding/maxDrivers mapping.
timings: {
qualifyingMinutes: 10,
mainRaceMinutes: 30,
sessionCount: 8,
},
});
const command: CreateSeasonForLeagueCommand = {
leagueId: 'league-1',
name: 'Season from Config',
gameId: 'iracing',
config,
};
const result = await useCase.execute(command);
expect(result.seasonId).toBeDefined();
const created = await seasonRepo.findById(result.seasonId);
expect(created).not.toBeNull();
const season = created!;
expect(season.leagueId).toBe('league-1');
expect(season.gameId).toBe('iracing');
expect(season.name).toBe('Season from Config');
expect(season.status).toBe('planned');
// Schedule is optional when timings lack seasonStartDate / raceStartTime.
expect(season.schedule).toBeUndefined();
expect(season.scoringConfig).toBeDefined();
expect(season.scoringConfig!.scoringPresetId).toBe('club-default');
expect(season.scoringConfig!.customScoringEnabled).toBe(true);
expect(season.dropPolicy).toBeDefined();
expect(season.dropPolicy!.strategy).toBe('dropWorstN');
expect(season.dropPolicy!.n).toBe(2);
expect(season.stewardingConfig).toBeDefined();
expect(season.maxDrivers).toBe(30);
});
it('clones configuration from a source season when sourceSeasonId is provided', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const sourceSeason = Season.create({
id: 'source-season',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Source Season',
status: 'planned',
}).withMaxDrivers(40);
await seasonRepo.add(sourceSeason);
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
const command: CreateSeasonForLeagueCommand = {
leagueId: 'league-1',
name: 'Cloned Season',
gameId: 'iracing',
sourceSeasonId: 'source-season',
};
const result = await useCase.execute(command);
const created = await seasonRepo.findById(result.seasonId);
expect(created).not.toBeNull();
const season = created!;
expect(season.id).not.toBe(sourceSeason.id);
expect(season.leagueId).toBe(sourceSeason.leagueId);
expect(season.gameId).toBe(sourceSeason.gameId);
expect(season.status).toBe('planned');
expect(season.maxDrivers).toBe(sourceSeason.maxDrivers);
expect(season.schedule).toBe(sourceSeason.schedule);
expect(season.scoringConfig).toBe(sourceSeason.scoringConfig);
expect(season.dropPolicy).toBe(sourceSeason.dropPolicy);
expect(season.stewardingConfig).toBe(sourceSeason.stewardingConfig);
});
});
describe('ListSeasonsForLeagueUseCase', () => {
it('lists seasons for a league with summaries', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const s1 = Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Season One',
status: 'planned',
});
const s2 = Season.create({
id: 'season-2',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Season Two',
status: 'active',
});
const sOtherLeague = Season.create({
id: 'season-3',
leagueId: 'league-2',
gameId: 'iracing',
name: 'Season Other',
status: 'planned',
});
await seasonRepo.add(s1);
await seasonRepo.add(s2);
await seasonRepo.add(sOtherLeague);
const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo);
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.items.map((i) => i.seasonId).sort()).toEqual([
'season-1',
'season-2',
]);
expect(result.items.every((i) => i.leagueId === 'league-1')).toBe(true);
});
});
describe('GetSeasonDetailsUseCase', () => {
it('returns full details for a season belonging to the league', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const season = Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Detailed Season',
status: 'planned',
}).withMaxDrivers(24);
await seasonRepo.add(season);
const useCase = new GetSeasonDetailsUseCase(leagueRepo, seasonRepo);
const dto = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
});
expect(dto.seasonId).toBe('season-1');
expect(dto.leagueId).toBe('league-1');
expect(dto.gameId).toBe('iracing');
expect(dto.name).toBe('Detailed Season');
expect(dto.status).toBe('planned');
expect(dto.maxDrivers).toBe(24);
});
});
describe('ManageSeasonLifecycleUseCase', () => {
function setupLifecycleTest() {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const season = Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Lifecycle Season',
status: 'planned',
});
seasonRepo.seed(season);
const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo);
return { leagueRepo, seasonRepo, useCase, season };
}
it('applies activate → complete → archive transitions and persists state', async () => {
const { useCase, seasonRepo, season } = setupLifecycleTest();
const activateCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: season.id,
transition: 'activate',
};
const activated = await useCase.execute(activateCommand);
expect(activated.status).toBe('active');
const completeCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: season.id,
transition: 'complete',
};
const completed = await useCase.execute(completeCommand);
expect(completed.status).toBe('completed');
const archiveCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: season.id,
transition: 'archive',
};
const archived = await useCase.execute(archiveCommand);
expect(archived.status).toBe('archived');
const persisted = await seasonRepo.findById(season.id);
expect(persisted!.status).toBe('archived');
});
it('propagates domain invariant errors for invalid transitions', async () => {
const { useCase, seasonRepo, season } = setupLifecycleTest();
const completeCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: season.id,
transition: 'complete',
};
await expect(useCase.execute(completeCommand)).rejects.toThrow();
const persisted = await seasonRepo.findById(season.id);
expect(persisted!.status).toBe('planned');
});
});

View File

@@ -0,0 +1,509 @@
import { describe, it, expect } from 'vitest';
import {
RacingDomainInvariantError,
RacingDomainValidationError,
} from '@gridpilot/racing/domain/errors/RacingDomainError';
import {
Season,
type SeasonStatus,
} from '@gridpilot/racing/domain/entities/Season';
import { SeasonScoringConfig } from '@gridpilot/racing/domain/value-objects/SeasonScoringConfig';
import {
SeasonDropPolicy,
type SeasonDropStrategy,
} from '@gridpilot/racing/domain/value-objects/SeasonDropPolicy';
import { SeasonStewardingConfig } from '@gridpilot/racing/domain/value-objects/SeasonStewardingConfig';
function createMinimalSeason(overrides?: Partial<Season> & { status?: SeasonStatus }) {
return Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Test Season',
status: overrides?.status ?? 'planned',
});
}
describe('Season aggregate lifecycle', () => {
it('transitions Planned → Active → Completed → Archived with timestamps', () => {
const planned = createMinimalSeason({ status: 'planned' });
const activated = planned.activate();
expect(activated.status).toBe('active');
expect(activated.startDate).toBeInstanceOf(Date);
expect(activated.endDate).toBeUndefined();
const completed = activated.complete();
expect(completed.status).toBe('completed');
expect(completed.startDate).toEqual(activated.startDate);
expect(completed.endDate).toBeInstanceOf(Date);
const archived = completed.archive();
expect(archived.status).toBe('archived');
expect(archived.startDate).toEqual(completed.startDate);
expect(archived.endDate).toEqual(completed.endDate);
});
it('throws when activating a non-planned season', () => {
const active = createMinimalSeason({ status: 'active' });
const completed = createMinimalSeason({ status: 'completed' });
const archived = createMinimalSeason({ status: 'archived' });
const cancelled = createMinimalSeason({ status: 'cancelled' });
expect(() => active.activate()).toThrow(RacingDomainInvariantError);
expect(() => completed.activate()).toThrow(RacingDomainInvariantError);
expect(() => archived.activate()).toThrow(RacingDomainInvariantError);
expect(() => cancelled.activate()).toThrow(RacingDomainInvariantError);
});
it('throws when completing a non-active season', () => {
const planned = createMinimalSeason({ status: 'planned' });
const completed = createMinimalSeason({ status: 'completed' });
const archived = createMinimalSeason({ status: 'archived' });
const cancelled = createMinimalSeason({ status: 'cancelled' });
expect(() => planned.complete()).toThrow(RacingDomainInvariantError);
expect(() => completed.complete()).toThrow(RacingDomainInvariantError);
expect(() => archived.complete()).toThrow(RacingDomainInvariantError);
expect(() => cancelled.complete()).toThrow(RacingDomainInvariantError);
});
it('throws when archiving a non-completed season', () => {
const planned = createMinimalSeason({ status: 'planned' });
const active = createMinimalSeason({ status: 'active' });
const archived = createMinimalSeason({ status: 'archived' });
const cancelled = createMinimalSeason({ status: 'cancelled' });
expect(() => planned.archive()).toThrow(RacingDomainInvariantError);
expect(() => active.archive()).toThrow(RacingDomainInvariantError);
expect(() => archived.archive()).toThrow(RacingDomainInvariantError);
expect(() => cancelled.archive()).toThrow(RacingDomainInvariantError);
});
it('allows cancelling planned or active seasons and rejects completed/archived', () => {
const planned = createMinimalSeason({ status: 'planned' });
const active = createMinimalSeason({ status: 'active' });
const completed = createMinimalSeason({ status: 'completed' });
const archived = createMinimalSeason({ status: 'archived' });
const cancelledFromPlanned = planned.cancel();
expect(cancelledFromPlanned.status).toBe('cancelled');
expect(cancelledFromPlanned.startDate).toBe(planned.startDate);
expect(cancelledFromPlanned.endDate).toBeInstanceOf(Date);
const cancelledFromActive = active.cancel();
expect(cancelledFromActive.status).toBe('cancelled');
expect(cancelledFromActive.startDate).toBe(active.startDate);
expect(cancelledFromActive.endDate).toBeInstanceOf(Date);
expect(() => completed.cancel()).toThrow(RacingDomainInvariantError);
expect(() => archived.cancel()).toThrow(RacingDomainInvariantError);
});
it('cancel is idempotent for already cancelled seasons', () => {
const planned = createMinimalSeason({ status: 'planned' });
const cancelled = planned.cancel();
const cancelledAgain = cancelled.cancel();
expect(cancelledAgain).toBe(cancelled);
});
it('canWithdrawFromWallet only when completed', () => {
const planned = createMinimalSeason({ status: 'planned' });
const active = createMinimalSeason({ status: 'active' });
const completed = createMinimalSeason({ status: 'completed' });
const archived = createMinimalSeason({ status: 'archived' });
const cancelled = createMinimalSeason({ status: 'cancelled' });
expect(planned.canWithdrawFromWallet()).toBe(false);
expect(active.canWithdrawFromWallet()).toBe(false);
expect(completed.canWithdrawFromWallet()).toBe(true);
expect(archived.canWithdrawFromWallet()).toBe(false);
expect(cancelled.canWithdrawFromWallet()).toBe(false);
});
});
describe('Season configuration updates', () => {
function createBaseSeason() {
return Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Config Season',
status: 'planned',
startDate: new Date('2025-01-01T00:00:00Z'),
endDate: undefined,
maxDrivers: 24,
});
}
it('withScoringConfig returns a new Season with updated scoringConfig only', () => {
const season = createBaseSeason();
const scoringConfig = new SeasonScoringConfig({
scoringPresetId: 'sprint-main-driver',
customScoringEnabled: true,
});
const updated = season.withScoringConfig(scoringConfig);
expect(updated).not.toBe(season);
expect(updated.scoringConfig).toBe(scoringConfig);
expect(updated.schedule).toBe(season.schedule);
expect(updated.dropPolicy).toBe(season.dropPolicy);
expect(updated.stewardingConfig).toBe(season.stewardingConfig);
expect(updated.maxDrivers).toBe(season.maxDrivers);
});
it('withDropPolicy returns a new Season with updated dropPolicy only', () => {
const season = createBaseSeason();
const dropPolicy = new SeasonDropPolicy({
strategy: 'bestNResults',
n: 3,
});
const updated = season.withDropPolicy(dropPolicy);
expect(updated).not.toBe(season);
expect(updated.dropPolicy).toBe(dropPolicy);
expect(updated.scoringConfig).toBe(season.scoringConfig);
expect(updated.schedule).toBe(season.schedule);
expect(updated.stewardingConfig).toBe(season.stewardingConfig);
expect(updated.maxDrivers).toBe(season.maxDrivers);
});
it('withStewardingConfig returns a new Season with updated stewardingConfig only', () => {
const season = createBaseSeason();
const stewardingConfig = new SeasonStewardingConfig({
decisionMode: 'steward_vote',
requiredVotes: 3,
requireDefense: true,
defenseTimeLimit: 24,
voteTimeLimit: 24,
protestDeadlineHours: 48,
stewardingClosesHours: 72,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
});
const updated = season.withStewardingConfig(stewardingConfig);
expect(updated).not.toBe(season);
expect(updated.stewardingConfig).toBe(stewardingConfig);
expect(updated.scoringConfig).toBe(season.scoringConfig);
expect(updated.schedule).toBe(season.schedule);
expect(updated.dropPolicy).toBe(season.dropPolicy);
expect(updated.maxDrivers).toBe(season.maxDrivers);
});
it('withMaxDrivers updates maxDrivers when positive', () => {
const season = createBaseSeason();
const updated = season.withMaxDrivers(30);
expect(updated.maxDrivers).toBe(30);
expect(updated.id).toBe(season.id);
expect(updated.leagueId).toBe(season.leagueId);
expect(updated.gameId).toBe(season.gameId);
});
it('withMaxDrivers allows undefined to clear value', () => {
const season = createBaseSeason();
const updated = season.withMaxDrivers(undefined);
expect(updated.maxDrivers).toBeUndefined();
});
it('withMaxDrivers rejects non-positive values', () => {
const season = createBaseSeason();
expect(() => season.withMaxDrivers(0)).toThrow(
RacingDomainValidationError,
);
expect(() => season.withMaxDrivers(-5)).toThrow(
RacingDomainValidationError,
);
});
});
describe('SeasonScoringConfig', () => {
it('constructs from preset id and customScoringEnabled', () => {
const config = new SeasonScoringConfig({
scoringPresetId: 'club-default',
customScoringEnabled: true,
});
expect(config.scoringPresetId).toBe('club-default');
expect(config.customScoringEnabled).toBe(true);
expect(config.props.scoringPresetId).toBe('club-default');
expect(config.props.customScoringEnabled).toBe(true);
});
it('normalizes customScoringEnabled to false when omitted', () => {
const config = new SeasonScoringConfig({
scoringPresetId: 'sprint-main-driver',
});
expect(config.customScoringEnabled).toBe(false);
expect(config.props.customScoringEnabled).toBeUndefined();
});
it('throws when scoringPresetId is empty', () => {
expect(
() =>
new SeasonScoringConfig({
// @ts-expect-error intentional invalid input
scoringPresetId: ' ',
}),
).toThrow(RacingDomainValidationError);
});
it('equals compares by preset id and customScoringEnabled', () => {
const a = new SeasonScoringConfig({
scoringPresetId: 'club-default',
customScoringEnabled: false,
});
const b = new SeasonScoringConfig({
scoringPresetId: 'club-default',
customScoringEnabled: false,
});
const c = new SeasonScoringConfig({
scoringPresetId: 'club-default',
customScoringEnabled: true,
});
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});
describe('SeasonDropPolicy', () => {
it('allows strategy "none" with undefined n', () => {
const policy = new SeasonDropPolicy({ strategy: 'none' });
expect(policy.strategy).toBe('none');
expect(policy.n).toBeUndefined();
});
it('throws when strategy "none" has n defined', () => {
expect(
() =>
new SeasonDropPolicy({
strategy: 'none',
n: 1,
}),
).toThrow(RacingDomainValidationError);
});
it('requires positive integer n for "bestNResults" and "dropWorstN"', () => {
const strategies: SeasonDropStrategy[] = ['bestNResults', 'dropWorstN'];
for (const strategy of strategies) {
expect(
() =>
new SeasonDropPolicy({
strategy,
n: 0,
}),
).toThrow(RacingDomainValidationError);
expect(
() =>
new SeasonDropPolicy({
strategy,
n: -1,
}),
).toThrow(RacingDomainValidationError);
}
const okBest = new SeasonDropPolicy({
strategy: 'bestNResults',
n: 3,
});
const okDrop = new SeasonDropPolicy({
strategy: 'dropWorstN',
n: 2,
});
expect(okBest.n).toBe(3);
expect(okDrop.n).toBe(2);
});
it('equals compares strategy and n', () => {
const a = new SeasonDropPolicy({ strategy: 'none' });
const b = new SeasonDropPolicy({ strategy: 'none' });
const c = new SeasonDropPolicy({ strategy: 'bestNResults', n: 3 });
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});
describe('SeasonStewardingConfig', () => {
it('creates a valid config with voting mode and requiredVotes', () => {
const config = new SeasonStewardingConfig({
decisionMode: 'steward_vote',
requiredVotes: 3,
requireDefense: true,
defenseTimeLimit: 24,
voteTimeLimit: 24,
protestDeadlineHours: 48,
stewardingClosesHours: 72,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
});
expect(config.decisionMode).toBe('steward_vote');
expect(config.requiredVotes).toBe(3);
expect(config.requireDefense).toBe(true);
expect(config.defenseTimeLimit).toBe(24);
expect(config.voteTimeLimit).toBe(24);
expect(config.protestDeadlineHours).toBe(48);
expect(config.stewardingClosesHours).toBe(72);
expect(config.notifyAccusedOnProtest).toBe(true);
expect(config.notifyOnVoteRequired).toBe(true);
});
it('throws when decisionMode is missing', () => {
expect(
() =>
new SeasonStewardingConfig({
// @ts-expect-error intentional invalid
decisionMode: undefined,
requireDefense: true,
defenseTimeLimit: 24,
voteTimeLimit: 24,
protestDeadlineHours: 48,
stewardingClosesHours: 72,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
}),
).toThrow(RacingDomainValidationError);
});
it('requires requiredVotes for voting/veto modes', () => {
const votingModes = [
'steward_vote',
'member_vote',
'steward_veto',
'member_veto',
] as const;
for (const mode of votingModes) {
expect(
() =>
new SeasonStewardingConfig({
decisionMode: mode,
// @ts-expect-error intentional invalid
requiredVotes: undefined,
requireDefense: true,
defenseTimeLimit: 24,
voteTimeLimit: 24,
protestDeadlineHours: 48,
stewardingClosesHours: 72,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
}),
).toThrow(RacingDomainValidationError);
}
});
it('validates numeric limits as non-negative / positive integers', () => {
expect(
() =>
new SeasonStewardingConfig({
decisionMode: 'steward_decides',
requireDefense: true,
defenseTimeLimit: -1,
voteTimeLimit: 24,
protestDeadlineHours: 48,
stewardingClosesHours: 72,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
}),
).toThrow(RacingDomainValidationError);
expect(
() =>
new SeasonStewardingConfig({
decisionMode: 'steward_decides',
requireDefense: true,
defenseTimeLimit: 24,
voteTimeLimit: 0,
protestDeadlineHours: 48,
stewardingClosesHours: 72,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
}),
).toThrow(RacingDomainValidationError);
expect(
() =>
new SeasonStewardingConfig({
decisionMode: 'steward_decides',
requireDefense: true,
defenseTimeLimit: 24,
voteTimeLimit: 24,
protestDeadlineHours: 0,
stewardingClosesHours: 72,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
}),
).toThrow(RacingDomainValidationError);
expect(
() =>
new SeasonStewardingConfig({
decisionMode: 'steward_decides',
requireDefense: true,
defenseTimeLimit: 24,
voteTimeLimit: 24,
protestDeadlineHours: 48,
stewardingClosesHours: 0,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
}),
).toThrow(RacingDomainValidationError);
});
it('equals compares all props', () => {
const a = new SeasonStewardingConfig({
decisionMode: 'steward_vote',
requiredVotes: 3,
requireDefense: true,
defenseTimeLimit: 24,
voteTimeLimit: 24,
protestDeadlineHours: 48,
stewardingClosesHours: 72,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
});
const b = new SeasonStewardingConfig({
decisionMode: 'steward_vote',
requiredVotes: 3,
requireDefense: true,
defenseTimeLimit: 24,
voteTimeLimit: 24,
protestDeadlineHours: 48,
stewardingClosesHours: 72,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
});
const c = new SeasonStewardingConfig({
decisionMode: 'steward_decides',
requireDefense: false,
defenseTimeLimit: 0,
voteTimeLimit: 24,
protestDeadlineHours: 24,
stewardingClosesHours: 48,
notifyAccusedOnProtest: false,
notifyOnVoteRequired: false,
});
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});

View File

@@ -5,6 +5,7 @@
"lib": ["ES2022", "DOM"],
"moduleResolution": "node",
"esModuleInterop": true,
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
@@ -12,7 +13,7 @@
"alwaysStrict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": false,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,

View File

@@ -1,5 +1,8 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"types": ["vitest", "node"]
},
"include": [
"packages/**/*",
"apps/**/*",

View File

@@ -4,6 +4,7 @@ import path from 'path';
export default defineConfig({
test: {
globals: true,
watch: false,
environment: 'jsdom',
setupFiles: ['tests/setup/vitest.setup.ts'],
include: ['tests/**/*.{test,spec}.?(c|m)[jt]s?(x)'],