streamline components
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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.'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
125
apps/website/lib/view-models/LeagueScoringSectionViewModel.ts
Normal file
125
apps/website/lib/view-models/LeagueScoringSectionViewModel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user