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

@@ -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';