streamline components
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user