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

@@ -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}</>;
}