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

@@ -1,253 +0,0 @@
# E2E Test Environment Improvements
## Problem Summary
Your original e2e test environment had several critical issues:
1. **Hybrid Architecture**: Website ran locally via Playwright's `webServer` while API/DB ran in Docker
2. **SWC Compilation Issues**: Next.js SWC had problems in Docker containers
3. **CI Incompatibility**: The hybrid approach wouldn't work reliably in CI environments
4. **Complex Setup**: Multiple scripts and port configurations needed
5. **Port Conflicts**: Multiple services competing for ports (3000, 3001, 3101, 5432, 5433)
## Root Cause Analysis
### SWC Compilation Issues in Docker
The SWC (Speedy Web Compiler) issues were caused by:
- Missing native build tools (Python, make, g++)
- File system performance issues with volume mounts
- Insufficient memory/CPU allocation
- Missing dependencies in Alpine Linux
### CI Incompatibility
The hybrid approach failed in CI because:
- CI environments don't have local Node.js processes
- Port management becomes complex
- Environment consistency is harder to maintain
- Debugging is more difficult
## Solution: Unified Docker Architecture
### Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Docker Network: gridpilot-e2e-network │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Playwright │───▶│ Website │───▶│ API │ │
│ │ Runner │ │ (Next.js) │ │ (NestJS) │ │
│ │ │ │ Port: 3000 │ │ Port: 3000 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ └───────────────────┴───────────────────┴───────────┘
│ │
│ ┌───────▼────────┐
│ │ PostgreSQL │
│ │ Port: 5432 │
│ └────────────────┘
└─────────────────────────────────────────────────────────────┘
```
### Key Components
#### 1. Optimized Next.js Dockerfile (`apps/website/Dockerfile.e2e`)
```dockerfile
# Includes all SWC build dependencies
RUN apk add --no-cache python3 make g++ git curl
# Multi-stage build for optimization
# Production stage only includes runtime dependencies
```
#### 2. Unified Docker Compose (`docker-compose.e2e.yml`)
- **Database**: PostgreSQL on port 5434
- **API**: NestJS on port 3101
- **Website**: Next.js on port 3100
- **Playwright**: Test runner in container
- All services on isolated network
#### 3. Updated Playwright Config
- No `webServer` - everything runs in Docker
- Base URL: `http://website:3000` (container network)
- No local dependencies
#### 4. Simplified Package.json Scripts
```bash
# Single command for complete e2e testing
npm run test:e2e:website
# Individual control commands
npm run docker:e2e:up
npm run docker:e2e:down
npm run docker:e2e:logs
npm run docker:e2e:clean
```
## Benefits
### ✅ Reliability
- **No SWC issues**: Optimized Dockerfile with all build tools
- **No port conflicts**: Isolated network and unique ports
- **No local dependencies**: Everything in containers
### ✅ CI Compatibility
- **Identical environments**: Local and CI run the same setup
- **Single command**: Easy to integrate in CI pipelines
- **Deterministic**: No "works on my machine" issues
### ✅ Developer Experience
- **Simplicity**: One command vs multiple steps
- **Debugging**: Easy log access and service management
- **Speed**: No local server startup overhead
### ✅ Maintainability
- **Single source of truth**: One docker-compose file
- **Clear documentation**: Updated README with migration guide
- **Future-proof**: Easy to extend and modify
## Migration Guide
### From Legacy to Unified
1. **Stop existing services**:
```bash
npm run docker:test:down
npm run docker:dev:down
```
2. **Clean up**:
```bash
npm run docker:test:clean
```
3. **Use new approach**:
```bash
npm run test:e2e:website
```
### What Changes
| Before | After |
|--------|-------|
| `npm run test:docker:website` | `npm run test:e2e:website` |
| Website: Local (3000) | Website: Docker (3100) |
| API: Docker (3101) | API: Docker (3101) |
| DB: Docker (5433) | DB: Docker (5434) |
| Playwright: Local | Playwright: Docker |
| 5+ commands | 1 command |
## Testing the New Setup
### Quick Test
```bash
# Run complete e2e test suite
npm run test:e2e:website
```
### Manual Verification
```bash
# Start services
npm run docker:e2e:up
# Check status
npm run docker:e2e:ps
# View logs
npm run docker:e2e:logs
# Test website manually
curl http://localhost:3100
# Test API manually
curl http://localhost:3101/health
# Stop services
npm run docker:e2e:down
```
## Files Created/Modified
### New Files
- `apps/website/Dockerfile.e2e` - Optimized Next.js image
- `docker-compose.e2e.yml` - Unified test environment
- `E2E_TESTING_IMPROVEMENTS.md` - This document
### Modified Files
- `playwright.website.config.ts` - Containerized setup
- `package.json` - New scripts
- `README.docker.md` - Updated documentation
## Troubleshooting
### Common Issues
**Issue**: "Website not building"
- **Solution**: Ensure Docker has 4GB+ memory
**Issue**: "Port already in use"
- **Solution**: `npm run docker:e2e:clean && npm run docker:e2e:up`
**Issue**: "Module not found"
- **Solution**: `npm run docker:e2e:clean` to rebuild
**Issue**: "Playwright timeout"
- **Solution**: Increase timeout in `playwright.website.config.ts`
### Debug Commands
```bash
# View all logs
npm run docker:e2e:logs
# Check specific service
docker-compose -f docker-compose.e2e.yml logs -f website
# Shell into container
docker-compose -f docker-compose.e2e.yml exec website sh
# Rebuild everything
npm run docker:e2e:clean && npm run docker:e2e:up
```
## Performance Comparison
### Before (Legacy Hybrid)
- **Startup time**: ~45-60 seconds
- **Reliability**: ~70% (SWC issues)
- **CI compatibility**: ❌ No
- **Commands needed**: 5+
### After (Unified Docker)
- **Startup time**: ~30-45 seconds
- **Reliability**: ~95%+
- **CI compatibility**: ✅ Yes
- **Commands needed**: 1
## Future Enhancements
### Potential Improvements
1. **Test Parallelization**: Run multiple test suites simultaneously
2. **Database Seeding**: Pre-seeded test data
3. **API Mocking**: Optional mock mode for faster tests
4. **Visual Testing**: Screenshot comparison tests
5. **Performance Testing**: Load testing integration
### CI Integration Example
```yaml
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run E2E Tests
run: npm run test:e2e:website
```
## Conclusion
This improvement transforms your e2e testing from a fragile, complex setup into a robust, CI-ready solution. The unified Docker approach eliminates SWC issues, simplifies the workflow, and ensures consistent behavior across all environments.
**Key Takeaway**: One command, one environment, zero headaches.

View File

@@ -1,144 +0,0 @@
# E2E Test Debugging Summary
## Problem
6 out of 12 e2e tests failing due to authentication/cookie issues.
## Root Cause Analysis
### Key Finding: Cookies Not Being Sent to Middleware
Through comprehensive debug logging, we discovered that:
1. **Cookies ARE being set correctly in Playwright**:
```
[WebsiteAuthManager] Cookies after setting: [
{
name: 'gp_session',
value: 'gp_bdecd1f8-6783-4946-8d2c-c817b0adfc71',
domain: 'website',
path: '/',
httpOnly: true,
sameSite: 'Lax'
}
]
```
2. **Cookies were NOT being received by the middleware** (INITIAL ISSUE):
```
[MIDDLEWARE] Request details | {
"pathname":"/sponsor/dashboard",
"cookieHeaderLength":0, // <-- NO COOKIES!
"cookiePreview":""
}
```
### The Issue
The problem was with how Playwright handles cookies when using `context.addCookies()` after context creation.
Playwright requires cookies to be set via the `storageState` option during context creation for reliable cookie handling in Docker environments.
### Solution Applied
✅ **Fixed cookie setting in WebsiteAuthManager**:
- Changed from `context.addCookies()` after context creation
- To using `storageState` option during `browser.newContext()`
- This ensures cookies are properly associated with the context from the start
```typescript
const contextWithCookies = await browser.newContext({
baseURL,
storageState: {
cookies: [{
name: 'gp_session',
value: token,
domain: new URL(baseURL).hostname,
path: '/',
expires: -1,
httpOnly: true,
secure: false,
sameSite: 'Lax',
}],
origins: [],
},
});
```
### Verification
After the fix, cookies ARE now being sent:
```
[MIDDLEWARE] Request details | {
"cookieHeaderLength":50,
"cookiePreview":"gp_session=gp_21c3a2d0-2810-44ba-8a4c-0a3bd359ff3d"
}
```
### Other Fixes Applied
1. ✅ **Added comprehensive debug logging** - Full transparency into auth flow
2. ✅ **Fixed ConsoleLogger to log in all environments** - Logs now visible in production/test
3. ✅ **Fixed cookie setting mechanism** - Using storageState instead of addCookies
## Next Steps
The real solution is likely one of:
1. **Use `localhost` instead of `website` hostname** in Docker
- Change PLAYWRIGHT_BASE_URL to use localhost with port mapping
- This would make cookie handling more standard
2. **Navigate to a page before setting cookies**
- Playwright needs an active page context to set cookies properly
- Set cookies after `page.goto()` instead of on the context
3. **Use Playwright's storage state feature**
- Save authenticated state and reuse it
- More reliable than manual cookie management
4. **Set cookies without domain parameter**
- Let Playwright infer the domain from the current page
## Debug Logs Added
We added permanent debug logs to:
- [`apps/website/middleware.ts`](apps/website/middleware.ts) - Request/session/auth flow logging
- [`apps/website/lib/auth/AuthFlowRouter.ts`](apps/website/lib/auth/AuthFlowRouter.ts) - Auth decision logging
- [`apps/website/lib/routing/RouteConfig.ts`](apps/website/lib/routing/RouteConfig.ts) - Route classification logging
- [`apps/website/lib/gateways/SessionGateway.ts`](apps/website/lib/gateways/SessionGateway.ts) - Session fetching logging
- [`apps/website/lib/infrastructure/logging/ConsoleLogger.ts`](apps/website/lib/infrastructure/logging/ConsoleLogger.ts) - Now logs in all environments
- [`tests/shared/website/WebsiteAuthManager.ts`](tests/shared/website/WebsiteAuthManager.ts) - Cookie setting verification
These logs provide full transparency into the authentication flow and will help with future debugging.
## Test Results
Current status: **6 failed, 6 passed** (improved from initial 6 failures)
Failing tests:
1. public routes are accessible without authentication
2. admin routes require admin role
3. sponsor routes require sponsor role
4. parameterized routes handle edge cases
5. no console or page errors on critical routes
6. TypeORM session persistence across routes
### Analysis of Remaining Failures
The cookie issue is **FIXED** - cookies are now being sent correctly. The remaining 6 failures are due to other issues:
1. **Test expectations may be incorrect** - Some tests expect specific behavior that doesn't match the actual implementation
2. **Route configuration issues** - Some routes may not be properly classified as public/protected
3. **Test data issues** - Edge case tests may be using invalid data
4. **Console error expectations** - The "no console errors" test may be too strict
The middleware and authentication flow are working correctly. The remaining failures need individual investigation and are NOT related to the cookie/session issue that was the focus of this debugging session.
## Conclusion
**Cookie issue RESOLVED** - Cookies are now being sent from Playwright to middleware
**Debug logging in place** - Full transparency for future debugging
**ConsoleLogger fixed** - Logs in all environments
**Documentation complete** - This summary provides full context
The remaining test failures are separate issues that need individual attention, but the core authentication/cookie mechanism is now working correctly.

View File

@@ -1,48 +0,0 @@
# Middleware Authentication Fix Summary
## Problem
6 out of 12 e2e tests failing due to middleware not properly protecting routes.
## Root Cause Analysis
### Issue 1: Cookie Loss in Redirect Chain
When navigating to `/sponsor`, the page component does a server-side `redirect('/sponsor/dashboard')` which loses cookies in the redirect chain. This causes the second request to `/sponsor/dashboard` to have no cookies.
**Evidence:**
```
/sponsor - cookie header length: 50 ✓
/sponsor/dashboard - cookie header length: 0 ✗
```
**Fix:** Handle `/sponsor``/sponsor/dashboard` redirect in middleware to preserve cookies.
### Issue 2: Auth Page Redirect Loop
When an authenticated user with insufficient permissions is redirected to `/auth/login?returnTo=/sponsor/dashboard`, the middleware immediately redirects them away from the login page because they're authenticated. This creates a conflict.
**Fix:** Allow authenticated users to access login pages if they have a `returnTo` parameter (indicating they were sent there due to insufficient permissions).
### Issue 3: SessionGateway Cookie Handling
The `SessionGateway.getSession()` method was checking `if (cookieHeader)` which evaluates to `false` for empty strings, causing it to fall through to server component context even when called from middleware with an empty cookie header.
**Fix:** Check `if (cookieHeader !== undefined)` instead.
## Changes Made
1. **apps/website/lib/gateways/SessionGateway.ts**
- Fixed cookie header check to use `!== undefined` instead of truthy check
2. **apps/website/middleware.ts**
- Added redirect from `/sponsor` to `/sponsor/dashboard` in middleware
- Added check for `returnTo` parameter in auth page logic
- Added comprehensive logging
3. **apps/website/app/sponsor/dashboard/page.tsx**
- Added `export const dynamic = 'force-dynamic'` (reverted - doesn't work with client components)
## Test Results
Still failing - need to investigate further.
## Next Steps
1. Check if cookies are being set with correct domain
2. Verify Playwright cookie handling in Docker environment
3. Consider if the test expectations are correct

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