streamline components

This commit is contained in:
2026-01-07 14:16:02 +01:00
parent 94d60527f4
commit 3b3971e653
16 changed files with 685 additions and 667 deletions

View File

@@ -3,7 +3,7 @@
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
import { useRaceResultsPageData } from '@/hooks/race/useRaceResultsPageData';
import { RaceResultsDataTransformer } from '@/lib/transformers/RaceResultsDataTransformer';
import { RaceResultsDataTransformer } from '@/lib/view-models/RaceResultsDataTransformer';
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useState } from 'react';

View File

@@ -5,6 +5,9 @@ import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateC
import { createPortal } from 'react-dom';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SETTINGS_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationViewModel';
// ============================================================================
// INFO FLYOUT COMPONENT
@@ -304,14 +307,6 @@ interface ScoringPatternSectionProps {
onUpdateCustomPoints?: (points: CustomPointsConfig) => void;
}
// Custom points configuration for inline editor
export interface CustomPointsConfig {
racePoints: number[];
poleBonusPoints: number;
fastestLapPoints: number;
leaderLapPoints: number;
}
const DEFAULT_CUSTOM_POINTS: CustomPointsConfig = {
racePoints: [25, 18, 15, 12, 10, 8, 6, 4, 2, 1],
poleBonusPoints: 1,
@@ -333,29 +328,19 @@ export function LeagueScoringSection({
patternOnly,
championshipsOnly,
}: LeagueScoringSectionProps) {
const leagueSettingsService = useInject(LEAGUE_SETTINGS_SERVICE_TOKEN);
const disabled = readOnly || !onChange;
const handleSelectPreset = (presetId: string) => {
if (disabled || !onChange) return;
onChange({
...form,
scoring: {
...form.scoring,
patternId: presetId,
customScoringEnabled: false,
},
});
const updatedForm = leagueSettingsService.selectScoringPreset(form, presetId);
onChange(updatedForm);
};
const handleToggleCustomScoring = () => {
if (disabled || !onChange) return;
onChange({
...form,
scoring: {
...form.scoring,
customScoringEnabled: !form.scoring.customScoringEnabled,
},
});
const updatedForm = leagueSettingsService.toggleCustomScoring(form);
onChange(updatedForm);
};
const patternProps: ScoringPatternSectionProps = {
@@ -465,6 +450,7 @@ export function ScoringPatternSection({
onToggleCustomScoring,
onUpdateCustomPoints,
}: ScoringPatternSectionProps) {
const leagueSettingsService = useInject(LEAGUE_SETTINGS_SERVICE_TOKEN);
const disabled = readOnly || !onChangePatternId;
const currentPreset = presets.find((p) => p.id === scoring.patternId) ?? null;
const isCustom = scoring.customScoringEnabled;
@@ -514,19 +500,11 @@ export function ScoringPatternSection({
};
const getPresetEmoji = (preset: LeagueScoringPresetViewModel) => {
const name = preset.name.toLowerCase();
if (name.includes('sprint') || name.includes('double')) return '⚡';
if (name.includes('endurance') || name.includes('long')) return '🏆';
if (name.includes('club') || name.includes('casual')) return '🏅';
return '🏁';
return leagueSettingsService.getPresetEmoji(preset);
};
const getPresetDescription = (preset: LeagueScoringPresetViewModel) => {
const name = preset.name.toLowerCase();
if (name.includes('sprint')) return 'Sprint + Feature race';
if (name.includes('endurance')) return 'Long-form endurance';
if (name.includes('club')) return 'Casual league format';
return preset.sessionSummary;
return leagueSettingsService.getPresetDescription(preset);
};
// Flyout state
@@ -939,6 +917,7 @@ export function ChampionshipsSection({
onChange,
readOnly,
}: ChampionshipsSectionProps) {
const leagueSettingsService = useInject(LEAGUE_SETTINGS_SERVICE_TOKEN);
const disabled = readOnly || !onChange;
const isTeamsMode = form.structure.mode === 'fixedTeams';
const [showChampFlyout, setShowChampFlyout] = useState(false);
@@ -948,13 +927,8 @@ export function ChampionshipsSection({
const updateChampionship = (key: keyof LeagueConfigFormModel['championships'], value: boolean) => {
if (!onChange) return;
onChange({
...form,
championships: {
...form.championships,
[key]: value,
},
});
const updatedForm = leagueSettingsService.updateChampionship(form, key, value);
onChange(updatedForm);
};
const championships = [
@@ -1157,4 +1131,4 @@ export function ChampionshipsSection({
</div>
</div>
);
}
}

View File

@@ -4,7 +4,6 @@ import { useState } from 'react';
import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO';
import type { ProtestIncidentDTO } from '@/lib/types/generated/ProtestIncidentDTO';
import { useFileProtest } from '@/hooks/race/useFileProtest';
import {
AlertTriangle,
@@ -16,11 +15,9 @@ import {
FileText,
CheckCircle2,
} from 'lucide-react';
type ProtestParticipant = {
id: string;
name: string;
};
import { useInject } from '@/lib/di/hooks/useInject';
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { ProtestParticipant } from '@/lib/services/protests/ProtestService';
interface FileProtestModalProps {
isOpen: boolean;
@@ -41,6 +38,7 @@ export default function FileProtestModal({
}: FileProtestModalProps) {
const fileProtestMutation = useFileProtest();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const protestService = useInject(PROTEST_SERVICE_TOKEN);
// Form state
const [accusedDriverId, setAccusedDriverId] = useState<string>('');
@@ -53,51 +51,41 @@ export default function FileProtestModal({
const otherParticipants = participants.filter(p => p.id !== protestingDriverId);
const handleSubmit = async () => {
// Validation
if (!accusedDriverId) {
setErrorMessage('Please select the driver you are protesting against.');
return;
try {
// Use ProtestService for validation and command construction
const command = protestService.constructFileProtestCommand({
raceId,
leagueId,
protestingDriverId,
accusedDriverId,
lap,
timeInRace,
description,
comment,
proofVideoUrl,
});
setErrorMessage(null);
// Use existing hook for the actual API call
fileProtestMutation.mutate(command, {
onSuccess: () => {
// Reset form state on success
setAccusedDriverId('');
setLap('');
setTimeInRace('');
setDescription('');
setComment('');
setProofVideoUrl('');
},
onError: (error) => {
setErrorMessage(error.message || 'Failed to file protest');
},
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to validate protest input';
setErrorMessage(errorMessage);
}
if (!lap || parseInt(lap, 10) < 0) {
setErrorMessage('Please enter a valid lap number.');
return;
}
if (!description.trim()) {
setErrorMessage('Please describe what happened.');
return;
}
setErrorMessage(null);
const incident: ProtestIncidentDTO = {
lap: parseInt(lap, 10),
description: description.trim(),
...(timeInRace ? { timeInRace: parseInt(timeInRace, 10) } : {}),
};
const command = {
raceId,
protestingDriverId,
accusedDriverId,
incident,
...(comment.trim() ? { comment: comment.trim() } : {}),
...(proofVideoUrl.trim() ? { proofVideoUrl: proofVideoUrl.trim() } : {}),
} satisfies FileProtestCommandDTO;
fileProtestMutation.mutate(command, {
onSuccess: () => {
// Reset form state on success
setAccusedDriverId('');
setLap('');
setTimeInRace('');
setDescription('');
setComment('');
setProofVideoUrl('');
},
onError: (error) => {
setErrorMessage(error.message || 'Failed to file protest');
},
});
};
const handleClose = () => {
@@ -207,7 +195,7 @@ export default function FileProtestModal({
/>
</div>
</div>
{/* Incident Description */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
@@ -223,7 +211,7 @@ export default function FileProtestModal({
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50 resize-none"
/>
</div>
{/* Additional Comment */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
@@ -239,7 +227,7 @@ export default function FileProtestModal({
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50 resize-none"
/>
</div>
{/* Video Proof */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
@@ -258,7 +246,7 @@ export default function FileProtestModal({
Providing video evidence significantly helps the stewards review your protest.
</p>
</div>
{/* Info Box */}
<div className="p-3 bg-iron-gray rounded-lg border border-charcoal-outline">
<p className="text-xs text-gray-400">
@@ -267,7 +255,7 @@ export default function FileProtestModal({
to grid penalties for future races, depending on the severity.
</p>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button
@@ -290,4 +278,4 @@ export default function FileProtestModal({
</div>
</Modal>
);
}
}

View File

@@ -1,18 +1,10 @@
'use client';
import { useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import Button from '../ui/Button';
interface ImportResultRowDTO {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { ImportResultRowDTO } from '@/lib/services/races/RaceResultsService';
interface ImportResultsFormProps {
raceId: string;
@@ -20,97 +12,10 @@ interface ImportResultsFormProps {
onError: (error: string) => void;
}
interface CSVRow {
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
export default function ImportResultsForm({ raceId, onSuccess, onError }: ImportResultsFormProps) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const parseCSV = (content: string): CSVRow[] => {
const lines = content.trim().split('\n');
if (lines.length < 2) {
throw new Error('CSV file is empty or invalid');
}
const headerLine = lines[0]!;
const header = headerLine.toLowerCase().split(',').map((h) => h.trim());
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
for (const field of requiredFields) {
if (!header.includes(field)) {
throw new Error(`Missing required field: ${field}`);
}
}
const rows: CSVRow[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) {
continue;
}
const values = line.split(',').map((v) => v.trim());
if (values.length !== header.length) {
throw new Error(
`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`,
);
}
const row: Record<string, string> = {};
header.forEach((field, index) => {
row[field] = values[index] ?? '';
});
const driverId = row['driverid'] ?? '';
const position = parseInt(row['position'] ?? '', 10);
const fastestLap = parseFloat(row['fastestlap'] ?? '');
const incidents = parseInt(row['incidents'] ?? '', 10);
const startPosition = parseInt(row['startposition'] ?? '', 10);
if (!driverId || driverId.length === 0) {
throw new Error(`Row ${i}: driverId is required`);
}
if (Number.isNaN(position) || position < 1) {
throw new Error(`Row ${i}: position must be a positive integer`);
}
if (Number.isNaN(fastestLap) || fastestLap < 0) {
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
}
if (Number.isNaN(incidents) || incidents < 0) {
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
}
if (Number.isNaN(startPosition) || startPosition < 1) {
throw new Error(`Row ${i}: startPosition must be a positive integer`);
}
rows.push({ driverId, position, fastestLap, incidents, startPosition });
}
const positions = rows.map((r) => r.position);
const uniquePositions = new Set(positions);
if (positions.length !== uniquePositions.size) {
throw new Error('Duplicate positions found in CSV');
}
const driverIds = rows.map((r) => r.driverId);
const uniqueDrivers = new Set(driverIds);
if (driverIds.length !== uniqueDrivers.size) {
throw new Error('Duplicate driver IDs found in CSV');
}
return rows;
};
const raceResultsService = useInject(RACE_RESULTS_SERVICE_TOKEN);
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -121,18 +26,7 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
try {
const content = await file.text();
const rows = parseCSV(content);
const results: ImportResultRowDTO[] = rows.map((row) => ({
id: uuidv4(),
raceId,
driverId: row.driverId,
position: row.position,
fastestLap: row.fastestLap,
incidents: row.incidents,
startPosition: row.startPosition,
}));
const results = raceResultsService.parseAndTransformCSV(content, raceId);
onSuccess(results);
} catch (err) {
const errorMessage =

View File

@@ -2,6 +2,8 @@
import { ReactNode } from 'react';
import { useCapability } from '@/hooks/useCapability';
import { useInject } from '@/lib/di/hooks/useInject';
import { POLICY_SERVICE_TOKEN } from '@/lib/di/tokens';
type CapabilityGateProps = {
capabilityKey: string;
@@ -16,19 +18,19 @@ export function CapabilityGate({
fallback = null,
comingSoon = null,
}: CapabilityGateProps) {
const { isLoading, isError, capabilityState } = useCapability(capabilityKey);
const policyService = useInject(POLICY_SERVICE_TOKEN);
const { isLoading, isError, data: snapshot } = useCapability(capabilityKey);
if (isLoading || isError || !capabilityState) {
return <>{fallback}</>;
}
// Use PolicyService to centralize the evaluation logic
const content = policyService.getCapabilityContent(
snapshot || null,
capabilityKey,
isLoading,
isError,
children,
fallback,
comingSoon
);
if (capabilityState === 'enabled') {
return <>{children}</>;
}
if (capabilityState === 'coming_soon') {
return <>{comingSoon ?? fallback}</>;
}
return <>{fallback}</>;
return <>{content}</>;
}

View File

@@ -4,6 +4,8 @@ import type { LeagueConfigFormModel } from "@/lib/types/LeagueConfigFormModel";
import type { LeagueScoringPresetDTO } from "@/lib/types/generated/LeagueScoringPresetDTO";
import { LeagueSettingsViewModel } from "@/lib/view-models/LeagueSettingsViewModel";
import { DriverSummaryViewModel } from "@/lib/view-models/DriverSummaryViewModel";
import type { LeagueScoringPresetViewModel } from "@/lib/view-models/LeagueScoringPresetViewModel";
import type { CustomPointsConfig } from "@/lib/view-models/ScoringConfigurationViewModel";
/**
* League Settings Service
@@ -102,4 +104,180 @@ export class LeagueSettingsService {
throw error;
}
}
/**
* Select a scoring preset
*/
selectScoringPreset(
currentForm: LeagueConfigFormModel,
presetId: string
): LeagueConfigFormModel {
return {
...currentForm,
scoring: {
...currentForm.scoring,
patternId: presetId,
customScoringEnabled: false,
},
};
}
/**
* Toggle custom scoring
*/
toggleCustomScoring(currentForm: LeagueConfigFormModel): LeagueConfigFormModel {
return {
...currentForm,
scoring: {
...currentForm.scoring,
customScoringEnabled: !currentForm.scoring.customScoringEnabled,
},
};
}
/**
* Update championship settings
*/
updateChampionship(
currentForm: LeagueConfigFormModel,
key: keyof LeagueConfigFormModel['championships'],
value: boolean
): LeagueConfigFormModel {
return {
...currentForm,
championships: {
...currentForm.championships,
[key]: value,
},
};
}
/**
* Get preset emoji based on name
*/
getPresetEmoji(preset: LeagueScoringPresetViewModel): string {
const name = preset.name.toLowerCase();
if (name.includes('sprint') || name.includes('double')) return '⚡';
if (name.includes('endurance') || name.includes('long')) return '🏆';
if (name.includes('club') || name.includes('casual')) return '🏅';
return '🏁';
}
/**
* Get preset description based on name
*/
getPresetDescription(preset: LeagueScoringPresetViewModel): string {
const name = preset.name.toLowerCase();
if (name.includes('sprint')) return 'Sprint + Feature race';
if (name.includes('endurance')) return 'Long-form endurance';
if (name.includes('club')) return 'Casual league format';
return preset.sessionSummary;
}
/**
* Get preset info content for flyout
*/
getPresetInfoContent(presetName: string): { title: string; description: string; details: string[] } {
const name = presetName.toLowerCase();
if (name.includes('sprint')) {
return {
title: 'Sprint + Feature Format',
description: 'A two-race weekend format with a shorter sprint race and a longer feature race.',
details: [
'Sprint race typically awards reduced points (e.g., 8-6-4-3-2-1)',
'Feature race awards full points (e.g., 25-18-15-12-10-8-6-4-2-1)',
'Grid for feature often based on sprint results',
'Great for competitive leagues with time for multiple races',
],
};
}
if (name.includes('endurance') || name.includes('long')) {
return {
title: 'Endurance Format',
description: 'Long-form racing focused on consistency and strategy over raw pace.',
details: [
'Single race per weekend, longer duration (60-90+ minutes)',
'Higher points for finishing (rewards reliability)',
'Often includes mandatory pit stops',
'Best for serious leagues with dedicated racers',
],
};
}
if (name.includes('club') || name.includes('casual')) {
return {
title: 'Club/Casual Format',
description: 'Relaxed format perfect for community leagues and casual racing.',
details: [
'Simple points structure, easy to understand',
'Typically single race per weekend',
'Lower stakes, focus on participation',
'Great for beginners or mixed-skill leagues',
],
};
}
return {
title: 'Standard Race Format',
description: 'Traditional single-race weekend with standard F1-style points.',
details: [
'Points: 25-18-15-12-10-8-6-4-2-1 for top 10',
'Bonus points for pole position and fastest lap',
'One race per weekend',
'The most common format used in sim racing',
],
};
}
/**
* Get championship info content for flyout
*/
getChampionshipInfoContent(key: string): { title: string; description: string; details: string[] } {
const info: Record<string, { title: string; description: string; details: string[] }> = {
enableDriverChampionship: {
title: 'Driver Championship',
description: 'Track individual driver performance across all races in the season.',
details: [
'Each driver accumulates points based on race finishes',
'The driver with most points at season end wins',
'Standard in all racing leagues',
'Shows overall driver skill and consistency',
],
},
enableTeamChampionship: {
title: 'Team Championship',
description: 'Combine points from all drivers within a team for team standings.',
details: [
'All drivers\' points count toward team total',
'Rewards having consistent performers across the roster',
'Creates team strategy opportunities',
'Only available in Teams mode leagues',
],
},
enableNationsChampionship: {
title: 'Nations Cup',
description: 'Group drivers by nationality for international competition.',
details: [
'Drivers represent their country automatically',
'Points pooled by nationality',
'Adds international rivalry element',
'Great for diverse, international leagues',
],
},
enableTrophyChampionship: {
title: 'Trophy Championship',
description: 'A special category championship for specific classes or groups.',
details: [
'Custom category you define (e.g., Am drivers, rookies)',
'Separate standings from main championship',
'Encourages participation from all skill levels',
'Can be used for gentleman drivers, newcomers, etc.',
],
},
};
return info[key] || {
title: 'Championship',
description: 'A championship standings category.',
details: ['Enable to track this type of championship.'],
};
}
}

View File

@@ -1,5 +1,13 @@
import type { FeatureState, PolicyApiClient, PolicySnapshotDto } from '../../api/policy/PolicyApiClient';
export interface CapabilityEvaluationResult {
isLoading: boolean;
isError: boolean;
capabilityState: FeatureState | null;
shouldShowChildren: boolean;
shouldShowComingSoon: boolean;
}
export class PolicyService {
constructor(private readonly apiClient: PolicyApiClient) {}
@@ -14,4 +22,61 @@ export class PolicyService {
isCapabilityEnabled(snapshot: PolicySnapshotDto, capabilityKey: string): boolean {
return this.getCapabilityState(snapshot, capabilityKey) === 'enabled';
}
/**
* Evaluate capability state and determine what should be rendered
* Centralizes the logic for capability-based UI rendering
*/
evaluateCapability(
snapshot: PolicySnapshotDto | null,
capabilityKey: string,
isLoading: boolean,
isError: boolean
): CapabilityEvaluationResult {
if (isLoading || isError || !snapshot) {
return {
isLoading,
isError,
capabilityState: null,
shouldShowChildren: false,
shouldShowComingSoon: false,
};
}
const capabilityState = this.getCapabilityState(snapshot, capabilityKey);
return {
isLoading,
isError,
capabilityState,
shouldShowChildren: capabilityState === 'enabled',
shouldShowComingSoon: capabilityState === 'coming_soon',
};
}
/**
* Get the appropriate content based on capability state
* Handles fallback and coming soon logic
*/
getCapabilityContent(
snapshot: PolicySnapshotDto | null,
capabilityKey: string,
isLoading: boolean,
isError: boolean,
children: React.ReactNode,
fallback: React.ReactNode = null,
comingSoon: React.ReactNode = null
): React.ReactNode {
const evaluation = this.evaluateCapability(snapshot, capabilityKey, isLoading, isError);
if (evaluation.shouldShowChildren) {
return children;
}
if (evaluation.shouldShowComingSoon) {
return comingSoon ?? fallback;
}
return fallback;
}
}

View File

@@ -7,6 +7,25 @@ import type { ApplyPenaltyCommandDTO } from '../../types/generated/ApplyPenaltyC
import type { RequestProtestDefenseCommandDTO } from '../../types/generated/RequestProtestDefenseCommandDTO';
import type { ReviewProtestCommandDTO } from '../../types/generated/ReviewProtestCommandDTO';
import type { DriverDTO } from '../../types/generated/DriverDTO';
import type { FileProtestCommandDTO } from '../../types/generated/FileProtestCommandDTO';
import type { ProtestIncidentDTO } from '../../types/generated/ProtestIncidentDTO';
export interface ProtestParticipant {
id: string;
name: string;
}
export interface FileProtestInput {
raceId: string;
leagueId?: string;
protestingDriverId: string;
accusedDriverId: string;
lap: string;
timeInRace?: string;
description: string;
comment?: string;
proofVideoUrl?: string;
}
/**
* Protest Service
@@ -104,4 +123,44 @@ export class ProtestService {
const dto = await this.apiClient.getRaceProtests(raceId);
return dto.protests;
}
/**
* Validate file protest input
* @throws Error with descriptive message if validation fails
*/
validateFileProtestInput(input: FileProtestInput): void {
if (!input.accusedDriverId) {
throw new Error('Please select the driver you are protesting against.');
}
if (!input.lap || parseInt(input.lap, 10) < 0) {
throw new Error('Please enter a valid lap number.');
}
if (!input.description.trim()) {
throw new Error('Please describe what happened.');
}
}
/**
* Construct file protest command from input
*/
constructFileProtestCommand(input: FileProtestInput): FileProtestCommandDTO {
this.validateFileProtestInput(input);
const incident: ProtestIncidentDTO = {
lap: parseInt(input.lap, 10),
description: input.description.trim(),
...(input.timeInRace ? { timeInRace: parseInt(input.timeInRace, 10) } : {}),
};
const command: FileProtestCommandDTO = {
raceId: input.raceId,
protestingDriverId: input.protestingDriverId,
accusedDriverId: input.accusedDriverId,
incident,
...(input.comment?.trim() ? { comment: input.comment.trim() } : {}),
...(input.proofVideoUrl?.trim() ? { proofVideoUrl: input.proofVideoUrl.trim() } : {}),
};
return command;
}
}

View File

@@ -3,6 +3,7 @@ import { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailV
import { RaceWithSOFViewModel } from '../../view-models/RaceWithSOFViewModel';
import { ImportRaceResultsSummaryViewModel } from '../../view-models/ImportRaceResultsSummaryViewModel';
import type { ImportRaceResultsDTO } from '../../types/generated/ImportRaceResultsDTO';
import { v4 as uuidv4 } from 'uuid';
// Define types
type ImportRaceResultsInputDto = ImportRaceResultsDTO;
@@ -14,6 +15,24 @@ type ImportRaceResultsSummaryDto = {
errors?: string[];
};
export interface ImportResultRowDTO {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
export interface CSVRow {
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
/**
* Race Results Service
*
@@ -48,4 +67,112 @@ export class RaceResultsService {
const dto = await this.apiClient.importResults(raceId, input);
return new ImportRaceResultsSummaryViewModel(dto);
}
/**
* Parse CSV content and validate results
* @throws Error with descriptive message if validation fails
*/
parseCSV(content: string): CSVRow[] {
const lines = content.trim().split('\n');
if (lines.length < 2) {
throw new Error('CSV file is empty or invalid');
}
const headerLine = lines[0]!;
const header = headerLine.toLowerCase().split(',').map((h) => h.trim());
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
for (const field of requiredFields) {
if (!header.includes(field)) {
throw new Error(`Missing required field: ${field}`);
}
}
const rows: CSVRow[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) {
continue;
}
const values = line.split(',').map((v) => v.trim());
if (values.length !== header.length) {
throw new Error(
`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`,
);
}
const row: Record<string, string> = {};
header.forEach((field, index) => {
row[field] = values[index] ?? '';
});
const driverId = row['driverid'] ?? '';
const position = parseInt(row['position'] ?? '', 10);
const fastestLap = parseFloat(row['fastestlap'] ?? '');
const incidents = parseInt(row['incidents'] ?? '', 10);
const startPosition = parseInt(row['startposition'] ?? '', 10);
if (!driverId || driverId.length === 0) {
throw new Error(`Row ${i}: driverId is required`);
}
if (Number.isNaN(position) || position < 1) {
throw new Error(`Row ${i}: position must be a positive integer`);
}
if (Number.isNaN(fastestLap) || fastestLap < 0) {
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
}
if (Number.isNaN(incidents) || incidents < 0) {
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
}
if (Number.isNaN(startPosition) || startPosition < 1) {
throw new Error(`Row ${i}: startPosition must be a positive integer`);
}
rows.push({ driverId, position, fastestLap, incidents, startPosition });
}
const positions = rows.map((r) => r.position);
const uniquePositions = new Set(positions);
if (positions.length !== uniquePositions.size) {
throw new Error('Duplicate positions found in CSV');
}
const driverIds = rows.map((r) => r.driverId);
const uniqueDrivers = new Set(driverIds);
if (driverIds.length !== uniqueDrivers.size) {
throw new Error('Duplicate driver IDs found in CSV');
}
return rows;
}
/**
* Transform parsed CSV rows into ImportResultRowDTO array
*/
transformToImportResults(rows: CSVRow[], raceId: string): ImportResultRowDTO[] {
return rows.map((row) => ({
id: uuidv4(),
raceId,
driverId: row.driverId,
position: row.position,
fastestLap: row.fastestLap,
incidents: row.incidents,
startPosition: row.startPosition,
}));
}
/**
* Parse CSV file content and transform to import results
* @throws Error with descriptive message if parsing or validation fails
*/
parseAndTransformCSV(content: string, raceId: string): ImportResultRowDTO[] {
const rows = this.parseCSV(content);
return this.transformToImportResults(rows, raceId);
}
}

View File

@@ -0,0 +1,125 @@
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationViewModel';
/**
* LeagueScoringSectionViewModel
*
* View model for the league scoring section UI state and operations
*/
export class LeagueScoringSectionViewModel {
readonly form: LeagueConfigFormModel;
readonly presets: LeagueScoringPresetViewModel[];
readonly readOnly: boolean;
readonly patternOnly: boolean;
readonly championshipsOnly: boolean;
readonly disabled: boolean;
readonly currentPreset: LeagueScoringPresetViewModel | null;
readonly isCustom: boolean;
constructor(
form: LeagueConfigFormModel,
presets: LeagueScoringPresetViewModel[],
options: {
readOnly?: boolean;
patternOnly?: boolean;
championshipsOnly?: boolean;
} = {}
) {
this.form = form;
this.presets = presets;
this.readOnly = options.readOnly || false;
this.patternOnly = options.patternOnly || false;
this.championshipsOnly = options.championshipsOnly || false;
this.disabled = this.readOnly;
this.currentPreset = form.scoring.patternId
? presets.find(p => p.id === form.scoring.patternId) || null
: null;
this.isCustom = form.scoring.customScoringEnabled || false;
}
/**
* Get default custom points configuration
*/
static getDefaultCustomPoints(): CustomPointsConfig {
return {
racePoints: [25, 18, 15, 12, 10, 8, 6, 4, 2, 1],
poleBonusPoints: 1,
fastestLapPoints: 1,
leaderLapPoints: 0,
};
}
/**
* Check if form can be modified
*/
canModify(): boolean {
return !this.readOnly;
}
/**
* Get available presets for display
*/
getAvailablePresets(): LeagueScoringPresetViewModel[] {
return this.presets;
}
/**
* Get championships configuration for display
*/
getChampionshipsConfig() {
const isTeamsMode = this.form.structure.mode === 'fixedTeams';
return [
{
key: 'enableDriverChampionship' as const,
label: 'Driver Standings',
description: 'Track individual driver points',
enabled: this.form.championships.enableDriverChampionship,
available: true,
},
{
key: 'enableTeamChampionship' as const,
label: 'Team Standings',
description: 'Combined team points',
enabled: this.form.championships.enableTeamChampionship,
available: isTeamsMode,
unavailableHint: 'Teams mode only',
},
{
key: 'enableNationsChampionship' as const,
label: 'Nations Cup',
description: 'By nationality',
enabled: this.form.championships.enableNationsChampionship,
available: true,
},
{
key: 'enableTrophyChampionship' as const,
label: 'Trophy Cup',
description: 'Special category',
enabled: this.form.championships.enableTrophyChampionship,
available: true,
},
];
}
/**
* Get panel visibility based on flags
*/
shouldShowPatternPanel(): boolean {
return !this.championshipsOnly;
}
shouldShowChampionshipsPanel(): boolean {
return !this.patternOnly;
}
/**
* Get the active custom points configuration
*/
getActiveCustomPoints(): CustomPointsConfig {
// This would be stored separately in the form model
// For now, return defaults
return LeagueScoringSectionViewModel.getDefaultCustomPoints();
}
}

View File

@@ -0,0 +1,50 @@
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
export interface CustomPointsConfig {
racePoints: number[];
poleBonusPoints: number;
fastestLapPoints: number;
leaderLapPoints: number;
}
/**
* ScoringConfigurationViewModel
*
* View model for scoring configuration including presets and custom points
*/
export class ScoringConfigurationViewModel {
readonly patternId?: string;
readonly customScoringEnabled: boolean;
readonly customPoints?: CustomPointsConfig;
readonly currentPreset?: LeagueScoringPresetViewModel;
constructor(
config: LeagueConfigFormModel['scoring'],
presets: LeagueScoringPresetViewModel[],
customPoints?: CustomPointsConfig
) {
this.patternId = config.patternId;
this.customScoringEnabled = config.customScoringEnabled || false;
this.customPoints = customPoints;
this.currentPreset = config.patternId
? presets.find(p => p.id === config.patternId)
: undefined;
}
/**
* Get the active points configuration
*/
getActivePointsConfig(): CustomPointsConfig {
if (this.customScoringEnabled && this.customPoints) {
return this.customPoints;
}
// Return default points if no custom config
return {
racePoints: [25, 18, 15, 12, 10, 8, 6, 4, 2, 1],
poleBonusPoints: 1,
fastestLapPoints: 1,
leaderLapPoints: 0,
};
}
}

View File

@@ -57,6 +57,7 @@ export * from './RaceDetailEntryViewModel';
export * from './RaceDetailUserResultViewModel';
export * from './RaceDetailViewModel';
export * from './RaceListItemViewModel';
export * from './RaceResultsDataTransformer';
export * from './RaceResultsDetailViewModel';
export * from './RaceResultViewModel';
export * from './RacesPageViewModel';