streamline components
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -3,7 +3,7 @@
|
||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
||||
import { useRaceResultsPageData } from '@/hooks/race/useRaceResultsPageData';
|
||||
import { RaceResultsDataTransformer } from '@/lib/transformers/RaceResultsDataTransformer';
|
||||
import { RaceResultsDataTransformer } from '@/lib/view-models/RaceResultsDataTransformer';
|
||||
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -5,6 +5,9 @@ import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateC
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
|
||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_SETTINGS_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationViewModel';
|
||||
|
||||
// ============================================================================
|
||||
// INFO FLYOUT COMPONENT
|
||||
@@ -304,14 +307,6 @@ interface ScoringPatternSectionProps {
|
||||
onUpdateCustomPoints?: (points: CustomPointsConfig) => void;
|
||||
}
|
||||
|
||||
// Custom points configuration for inline editor
|
||||
export interface CustomPointsConfig {
|
||||
racePoints: number[];
|
||||
poleBonusPoints: number;
|
||||
fastestLapPoints: number;
|
||||
leaderLapPoints: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CUSTOM_POINTS: CustomPointsConfig = {
|
||||
racePoints: [25, 18, 15, 12, 10, 8, 6, 4, 2, 1],
|
||||
poleBonusPoints: 1,
|
||||
@@ -333,29 +328,19 @@ export function LeagueScoringSection({
|
||||
patternOnly,
|
||||
championshipsOnly,
|
||||
}: LeagueScoringSectionProps) {
|
||||
const leagueSettingsService = useInject(LEAGUE_SETTINGS_SERVICE_TOKEN);
|
||||
const disabled = readOnly || !onChange;
|
||||
|
||||
const handleSelectPreset = (presetId: string) => {
|
||||
if (disabled || !onChange) return;
|
||||
onChange({
|
||||
...form,
|
||||
scoring: {
|
||||
...form.scoring,
|
||||
patternId: presetId,
|
||||
customScoringEnabled: false,
|
||||
},
|
||||
});
|
||||
const updatedForm = leagueSettingsService.selectScoringPreset(form, presetId);
|
||||
onChange(updatedForm);
|
||||
};
|
||||
|
||||
const handleToggleCustomScoring = () => {
|
||||
if (disabled || !onChange) return;
|
||||
onChange({
|
||||
...form,
|
||||
scoring: {
|
||||
...form.scoring,
|
||||
customScoringEnabled: !form.scoring.customScoringEnabled,
|
||||
},
|
||||
});
|
||||
const updatedForm = leagueSettingsService.toggleCustomScoring(form);
|
||||
onChange(updatedForm);
|
||||
};
|
||||
|
||||
const patternProps: ScoringPatternSectionProps = {
|
||||
@@ -465,6 +450,7 @@ export function ScoringPatternSection({
|
||||
onToggleCustomScoring,
|
||||
onUpdateCustomPoints,
|
||||
}: ScoringPatternSectionProps) {
|
||||
const leagueSettingsService = useInject(LEAGUE_SETTINGS_SERVICE_TOKEN);
|
||||
const disabled = readOnly || !onChangePatternId;
|
||||
const currentPreset = presets.find((p) => p.id === scoring.patternId) ?? null;
|
||||
const isCustom = scoring.customScoringEnabled;
|
||||
@@ -514,19 +500,11 @@ export function ScoringPatternSection({
|
||||
};
|
||||
|
||||
const getPresetEmoji = (preset: LeagueScoringPresetViewModel) => {
|
||||
const name = preset.name.toLowerCase();
|
||||
if (name.includes('sprint') || name.includes('double')) return '⚡';
|
||||
if (name.includes('endurance') || name.includes('long')) return '🏆';
|
||||
if (name.includes('club') || name.includes('casual')) return '🏅';
|
||||
return '🏁';
|
||||
return leagueSettingsService.getPresetEmoji(preset);
|
||||
};
|
||||
|
||||
const getPresetDescription = (preset: LeagueScoringPresetViewModel) => {
|
||||
const name = preset.name.toLowerCase();
|
||||
if (name.includes('sprint')) return 'Sprint + Feature race';
|
||||
if (name.includes('endurance')) return 'Long-form endurance';
|
||||
if (name.includes('club')) return 'Casual league format';
|
||||
return preset.sessionSummary;
|
||||
return leagueSettingsService.getPresetDescription(preset);
|
||||
};
|
||||
|
||||
// Flyout state
|
||||
@@ -939,6 +917,7 @@ export function ChampionshipsSection({
|
||||
onChange,
|
||||
readOnly,
|
||||
}: ChampionshipsSectionProps) {
|
||||
const leagueSettingsService = useInject(LEAGUE_SETTINGS_SERVICE_TOKEN);
|
||||
const disabled = readOnly || !onChange;
|
||||
const isTeamsMode = form.structure.mode === 'fixedTeams';
|
||||
const [showChampFlyout, setShowChampFlyout] = useState(false);
|
||||
@@ -948,13 +927,8 @@ export function ChampionshipsSection({
|
||||
|
||||
const updateChampionship = (key: keyof LeagueConfigFormModel['championships'], value: boolean) => {
|
||||
if (!onChange) return;
|
||||
onChange({
|
||||
...form,
|
||||
championships: {
|
||||
...form.championships,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
const updatedForm = leagueSettingsService.updateChampionship(form, key, value);
|
||||
onChange(updatedForm);
|
||||
};
|
||||
|
||||
const championships = [
|
||||
@@ -1157,4 +1131,4 @@ export function ChampionshipsSection({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useState } from 'react';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import Button from '@/components/ui/Button';
|
||||
import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO';
|
||||
import type { ProtestIncidentDTO } from '@/lib/types/generated/ProtestIncidentDTO';
|
||||
import { useFileProtest } from '@/hooks/race/useFileProtest';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -16,11 +15,9 @@ import {
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
|
||||
type ProtestParticipant = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type { ProtestParticipant } from '@/lib/services/protests/ProtestService';
|
||||
|
||||
interface FileProtestModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -41,6 +38,7 @@ export default function FileProtestModal({
|
||||
}: FileProtestModalProps) {
|
||||
const fileProtestMutation = useFileProtest();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const protestService = useInject(PROTEST_SERVICE_TOKEN);
|
||||
|
||||
// Form state
|
||||
const [accusedDriverId, setAccusedDriverId] = useState<string>('');
|
||||
@@ -53,51 +51,41 @@ export default function FileProtestModal({
|
||||
const otherParticipants = participants.filter(p => p.id !== protestingDriverId);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validation
|
||||
if (!accusedDriverId) {
|
||||
setErrorMessage('Please select the driver you are protesting against.');
|
||||
return;
|
||||
try {
|
||||
// Use ProtestService for validation and command construction
|
||||
const command = protestService.constructFileProtestCommand({
|
||||
raceId,
|
||||
leagueId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
lap,
|
||||
timeInRace,
|
||||
description,
|
||||
comment,
|
||||
proofVideoUrl,
|
||||
});
|
||||
|
||||
setErrorMessage(null);
|
||||
|
||||
// Use existing hook for the actual API call
|
||||
fileProtestMutation.mutate(command, {
|
||||
onSuccess: () => {
|
||||
// Reset form state on success
|
||||
setAccusedDriverId('');
|
||||
setLap('');
|
||||
setTimeInRace('');
|
||||
setDescription('');
|
||||
setComment('');
|
||||
setProofVideoUrl('');
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage(error.message || 'Failed to file protest');
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to validate protest input';
|
||||
setErrorMessage(errorMessage);
|
||||
}
|
||||
if (!lap || parseInt(lap, 10) < 0) {
|
||||
setErrorMessage('Please enter a valid lap number.');
|
||||
return;
|
||||
}
|
||||
if (!description.trim()) {
|
||||
setErrorMessage('Please describe what happened.');
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
|
||||
const incident: ProtestIncidentDTO = {
|
||||
lap: parseInt(lap, 10),
|
||||
description: description.trim(),
|
||||
...(timeInRace ? { timeInRace: parseInt(timeInRace, 10) } : {}),
|
||||
};
|
||||
|
||||
const command = {
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
incident,
|
||||
...(comment.trim() ? { comment: comment.trim() } : {}),
|
||||
...(proofVideoUrl.trim() ? { proofVideoUrl: proofVideoUrl.trim() } : {}),
|
||||
} satisfies FileProtestCommandDTO;
|
||||
|
||||
fileProtestMutation.mutate(command, {
|
||||
onSuccess: () => {
|
||||
// Reset form state on success
|
||||
setAccusedDriverId('');
|
||||
setLap('');
|
||||
setTimeInRace('');
|
||||
setDescription('');
|
||||
setComment('');
|
||||
setProofVideoUrl('');
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage(error.message || 'Failed to file protest');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -207,7 +195,7 @@ export default function FileProtestModal({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Incident Description */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
|
||||
@@ -223,7 +211,7 @@ export default function FileProtestModal({
|
||||
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Additional Comment */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
|
||||
@@ -239,7 +227,7 @@ export default function FileProtestModal({
|
||||
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Video Proof */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
|
||||
@@ -258,7 +246,7 @@ export default function FileProtestModal({
|
||||
Providing video evidence significantly helps the stewards review your protest.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="p-3 bg-iron-gray rounded-lg border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-400">
|
||||
@@ -267,7 +255,7 @@ export default function FileProtestModal({
|
||||
to grid penalties for future races, depending on the severity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
@@ -290,4 +278,4 @@ export default function FileProtestModal({
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface ImportResultRowDTO {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type { ImportResultRowDTO } from '@/lib/services/races/RaceResultsService';
|
||||
|
||||
interface ImportResultsFormProps {
|
||||
raceId: string;
|
||||
@@ -20,97 +12,10 @@ interface ImportResultsFormProps {
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
interface CSVRow {
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}
|
||||
|
||||
export default function ImportResultsForm({ raceId, onSuccess, onError }: ImportResultsFormProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const parseCSV = (content: string): CSVRow[] => {
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
if (lines.length < 2) {
|
||||
throw new Error('CSV file is empty or invalid');
|
||||
}
|
||||
|
||||
const headerLine = lines[0]!;
|
||||
const header = headerLine.toLowerCase().split(',').map((h) => h.trim());
|
||||
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!header.includes(field)) {
|
||||
throw new Error(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: CSVRow[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
const values = line.split(',').map((v) => v.trim());
|
||||
|
||||
if (values.length !== header.length) {
|
||||
throw new Error(
|
||||
`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const row: Record<string, string> = {};
|
||||
header.forEach((field, index) => {
|
||||
row[field] = values[index] ?? '';
|
||||
});
|
||||
|
||||
const driverId = row['driverid'] ?? '';
|
||||
const position = parseInt(row['position'] ?? '', 10);
|
||||
const fastestLap = parseFloat(row['fastestlap'] ?? '');
|
||||
const incidents = parseInt(row['incidents'] ?? '', 10);
|
||||
const startPosition = parseInt(row['startposition'] ?? '', 10);
|
||||
|
||||
if (!driverId || driverId.length === 0) {
|
||||
throw new Error(`Row ${i}: driverId is required`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(position) || position < 1) {
|
||||
throw new Error(`Row ${i}: position must be a positive integer`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(fastestLap) || fastestLap < 0) {
|
||||
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(incidents) || incidents < 0) {
|
||||
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(startPosition) || startPosition < 1) {
|
||||
throw new Error(`Row ${i}: startPosition must be a positive integer`);
|
||||
}
|
||||
|
||||
rows.push({ driverId, position, fastestLap, incidents, startPosition });
|
||||
}
|
||||
|
||||
const positions = rows.map((r) => r.position);
|
||||
const uniquePositions = new Set(positions);
|
||||
if (positions.length !== uniquePositions.size) {
|
||||
throw new Error('Duplicate positions found in CSV');
|
||||
}
|
||||
|
||||
const driverIds = rows.map((r) => r.driverId);
|
||||
const uniqueDrivers = new Set(driverIds);
|
||||
if (driverIds.length !== uniqueDrivers.size) {
|
||||
throw new Error('Duplicate driver IDs found in CSV');
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
const raceResultsService = useInject(RACE_RESULTS_SERVICE_TOKEN);
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -121,18 +26,7 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
const rows = parseCSV(content);
|
||||
|
||||
const results: ImportResultRowDTO[] = rows.map((row) => ({
|
||||
id: uuidv4(),
|
||||
raceId,
|
||||
driverId: row.driverId,
|
||||
position: row.position,
|
||||
fastestLap: row.fastestLap,
|
||||
incidents: row.incidents,
|
||||
startPosition: row.startPosition,
|
||||
}));
|
||||
|
||||
const results = raceResultsService.parseAndTransformCSV(content, raceId);
|
||||
onSuccess(results);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { useCapability } from '@/hooks/useCapability';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { POLICY_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
|
||||
type CapabilityGateProps = {
|
||||
capabilityKey: string;
|
||||
@@ -16,19 +18,19 @@ export function CapabilityGate({
|
||||
fallback = null,
|
||||
comingSoon = null,
|
||||
}: CapabilityGateProps) {
|
||||
const { isLoading, isError, capabilityState } = useCapability(capabilityKey);
|
||||
const policyService = useInject(POLICY_SERVICE_TOKEN);
|
||||
const { isLoading, isError, data: snapshot } = useCapability(capabilityKey);
|
||||
|
||||
if (isLoading || isError || !capabilityState) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
// Use PolicyService to centralize the evaluation logic
|
||||
const content = policyService.getCapabilityContent(
|
||||
snapshot || null,
|
||||
capabilityKey,
|
||||
isLoading,
|
||||
isError,
|
||||
children,
|
||||
fallback,
|
||||
comingSoon
|
||||
);
|
||||
|
||||
if (capabilityState === 'enabled') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (capabilityState === 'coming_soon') {
|
||||
return <>{comingSoon ?? fallback}</>;
|
||||
}
|
||||
|
||||
return <>{fallback}</>;
|
||||
return <>{content}</>;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { LeagueConfigFormModel } from "@/lib/types/LeagueConfigFormModel";
|
||||
import type { LeagueScoringPresetDTO } from "@/lib/types/generated/LeagueScoringPresetDTO";
|
||||
import { LeagueSettingsViewModel } from "@/lib/view-models/LeagueSettingsViewModel";
|
||||
import { DriverSummaryViewModel } from "@/lib/view-models/DriverSummaryViewModel";
|
||||
import type { LeagueScoringPresetViewModel } from "@/lib/view-models/LeagueScoringPresetViewModel";
|
||||
import type { CustomPointsConfig } from "@/lib/view-models/ScoringConfigurationViewModel";
|
||||
|
||||
/**
|
||||
* League Settings Service
|
||||
@@ -102,4 +104,180 @@ export class LeagueSettingsService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a scoring preset
|
||||
*/
|
||||
selectScoringPreset(
|
||||
currentForm: LeagueConfigFormModel,
|
||||
presetId: string
|
||||
): LeagueConfigFormModel {
|
||||
return {
|
||||
...currentForm,
|
||||
scoring: {
|
||||
...currentForm.scoring,
|
||||
patternId: presetId,
|
||||
customScoringEnabled: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle custom scoring
|
||||
*/
|
||||
toggleCustomScoring(currentForm: LeagueConfigFormModel): LeagueConfigFormModel {
|
||||
return {
|
||||
...currentForm,
|
||||
scoring: {
|
||||
...currentForm.scoring,
|
||||
customScoringEnabled: !currentForm.scoring.customScoringEnabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update championship settings
|
||||
*/
|
||||
updateChampionship(
|
||||
currentForm: LeagueConfigFormModel,
|
||||
key: keyof LeagueConfigFormModel['championships'],
|
||||
value: boolean
|
||||
): LeagueConfigFormModel {
|
||||
return {
|
||||
...currentForm,
|
||||
championships: {
|
||||
...currentForm.championships,
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preset emoji based on name
|
||||
*/
|
||||
getPresetEmoji(preset: LeagueScoringPresetViewModel): string {
|
||||
const name = preset.name.toLowerCase();
|
||||
if (name.includes('sprint') || name.includes('double')) return '⚡';
|
||||
if (name.includes('endurance') || name.includes('long')) return '🏆';
|
||||
if (name.includes('club') || name.includes('casual')) return '🏅';
|
||||
return '🏁';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preset description based on name
|
||||
*/
|
||||
getPresetDescription(preset: LeagueScoringPresetViewModel): string {
|
||||
const name = preset.name.toLowerCase();
|
||||
if (name.includes('sprint')) return 'Sprint + Feature race';
|
||||
if (name.includes('endurance')) return 'Long-form endurance';
|
||||
if (name.includes('club')) return 'Casual league format';
|
||||
return preset.sessionSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preset info content for flyout
|
||||
*/
|
||||
getPresetInfoContent(presetName: string): { title: string; description: string; details: string[] } {
|
||||
const name = presetName.toLowerCase();
|
||||
if (name.includes('sprint')) {
|
||||
return {
|
||||
title: 'Sprint + Feature Format',
|
||||
description: 'A two-race weekend format with a shorter sprint race and a longer feature race.',
|
||||
details: [
|
||||
'Sprint race typically awards reduced points (e.g., 8-6-4-3-2-1)',
|
||||
'Feature race awards full points (e.g., 25-18-15-12-10-8-6-4-2-1)',
|
||||
'Grid for feature often based on sprint results',
|
||||
'Great for competitive leagues with time for multiple races',
|
||||
],
|
||||
};
|
||||
}
|
||||
if (name.includes('endurance') || name.includes('long')) {
|
||||
return {
|
||||
title: 'Endurance Format',
|
||||
description: 'Long-form racing focused on consistency and strategy over raw pace.',
|
||||
details: [
|
||||
'Single race per weekend, longer duration (60-90+ minutes)',
|
||||
'Higher points for finishing (rewards reliability)',
|
||||
'Often includes mandatory pit stops',
|
||||
'Best for serious leagues with dedicated racers',
|
||||
],
|
||||
};
|
||||
}
|
||||
if (name.includes('club') || name.includes('casual')) {
|
||||
return {
|
||||
title: 'Club/Casual Format',
|
||||
description: 'Relaxed format perfect for community leagues and casual racing.',
|
||||
details: [
|
||||
'Simple points structure, easy to understand',
|
||||
'Typically single race per weekend',
|
||||
'Lower stakes, focus on participation',
|
||||
'Great for beginners or mixed-skill leagues',
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Standard Race Format',
|
||||
description: 'Traditional single-race weekend with standard F1-style points.',
|
||||
details: [
|
||||
'Points: 25-18-15-12-10-8-6-4-2-1 for top 10',
|
||||
'Bonus points for pole position and fastest lap',
|
||||
'One race per weekend',
|
||||
'The most common format used in sim racing',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get championship info content for flyout
|
||||
*/
|
||||
getChampionshipInfoContent(key: string): { title: string; description: string; details: string[] } {
|
||||
const info: Record<string, { title: string; description: string; details: string[] }> = {
|
||||
enableDriverChampionship: {
|
||||
title: 'Driver Championship',
|
||||
description: 'Track individual driver performance across all races in the season.',
|
||||
details: [
|
||||
'Each driver accumulates points based on race finishes',
|
||||
'The driver with most points at season end wins',
|
||||
'Standard in all racing leagues',
|
||||
'Shows overall driver skill and consistency',
|
||||
],
|
||||
},
|
||||
enableTeamChampionship: {
|
||||
title: 'Team Championship',
|
||||
description: 'Combine points from all drivers within a team for team standings.',
|
||||
details: [
|
||||
'All drivers\' points count toward team total',
|
||||
'Rewards having consistent performers across the roster',
|
||||
'Creates team strategy opportunities',
|
||||
'Only available in Teams mode leagues',
|
||||
],
|
||||
},
|
||||
enableNationsChampionship: {
|
||||
title: 'Nations Cup',
|
||||
description: 'Group drivers by nationality for international competition.',
|
||||
details: [
|
||||
'Drivers represent their country automatically',
|
||||
'Points pooled by nationality',
|
||||
'Adds international rivalry element',
|
||||
'Great for diverse, international leagues',
|
||||
],
|
||||
},
|
||||
enableTrophyChampionship: {
|
||||
title: 'Trophy Championship',
|
||||
description: 'A special category championship for specific classes or groups.',
|
||||
details: [
|
||||
'Custom category you define (e.g., Am drivers, rookies)',
|
||||
'Separate standings from main championship',
|
||||
'Encourages participation from all skill levels',
|
||||
'Can be used for gentleman drivers, newcomers, etc.',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return info[key] || {
|
||||
title: 'Championship',
|
||||
description: 'A championship standings category.',
|
||||
details: ['Enable to track this type of championship.'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { FeatureState, PolicyApiClient, PolicySnapshotDto } from '../../api/policy/PolicyApiClient';
|
||||
|
||||
export interface CapabilityEvaluationResult {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
capabilityState: FeatureState | null;
|
||||
shouldShowChildren: boolean;
|
||||
shouldShowComingSoon: boolean;
|
||||
}
|
||||
|
||||
export class PolicyService {
|
||||
constructor(private readonly apiClient: PolicyApiClient) {}
|
||||
|
||||
@@ -14,4 +22,61 @@ export class PolicyService {
|
||||
isCapabilityEnabled(snapshot: PolicySnapshotDto, capabilityKey: string): boolean {
|
||||
return this.getCapabilityState(snapshot, capabilityKey) === 'enabled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate capability state and determine what should be rendered
|
||||
* Centralizes the logic for capability-based UI rendering
|
||||
*/
|
||||
evaluateCapability(
|
||||
snapshot: PolicySnapshotDto | null,
|
||||
capabilityKey: string,
|
||||
isLoading: boolean,
|
||||
isError: boolean
|
||||
): CapabilityEvaluationResult {
|
||||
if (isLoading || isError || !snapshot) {
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
capabilityState: null,
|
||||
shouldShowChildren: false,
|
||||
shouldShowComingSoon: false,
|
||||
};
|
||||
}
|
||||
|
||||
const capabilityState = this.getCapabilityState(snapshot, capabilityKey);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isError,
|
||||
capabilityState,
|
||||
shouldShowChildren: capabilityState === 'enabled',
|
||||
shouldShowComingSoon: capabilityState === 'coming_soon',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate content based on capability state
|
||||
* Handles fallback and coming soon logic
|
||||
*/
|
||||
getCapabilityContent(
|
||||
snapshot: PolicySnapshotDto | null,
|
||||
capabilityKey: string,
|
||||
isLoading: boolean,
|
||||
isError: boolean,
|
||||
children: React.ReactNode,
|
||||
fallback: React.ReactNode = null,
|
||||
comingSoon: React.ReactNode = null
|
||||
): React.ReactNode {
|
||||
const evaluation = this.evaluateCapability(snapshot, capabilityKey, isLoading, isError);
|
||||
|
||||
if (evaluation.shouldShowChildren) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (evaluation.shouldShowComingSoon) {
|
||||
return comingSoon ?? fallback;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,25 @@ import type { ApplyPenaltyCommandDTO } from '../../types/generated/ApplyPenaltyC
|
||||
import type { RequestProtestDefenseCommandDTO } from '../../types/generated/RequestProtestDefenseCommandDTO';
|
||||
import type { ReviewProtestCommandDTO } from '../../types/generated/ReviewProtestCommandDTO';
|
||||
import type { DriverDTO } from '../../types/generated/DriverDTO';
|
||||
import type { FileProtestCommandDTO } from '../../types/generated/FileProtestCommandDTO';
|
||||
import type { ProtestIncidentDTO } from '../../types/generated/ProtestIncidentDTO';
|
||||
|
||||
export interface ProtestParticipant {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FileProtestInput {
|
||||
raceId: string;
|
||||
leagueId?: string;
|
||||
protestingDriverId: string;
|
||||
accusedDriverId: string;
|
||||
lap: string;
|
||||
timeInRace?: string;
|
||||
description: string;
|
||||
comment?: string;
|
||||
proofVideoUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protest Service
|
||||
@@ -104,4 +123,44 @@ export class ProtestService {
|
||||
const dto = await this.apiClient.getRaceProtests(raceId);
|
||||
return dto.protests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file protest input
|
||||
* @throws Error with descriptive message if validation fails
|
||||
*/
|
||||
validateFileProtestInput(input: FileProtestInput): void {
|
||||
if (!input.accusedDriverId) {
|
||||
throw new Error('Please select the driver you are protesting against.');
|
||||
}
|
||||
if (!input.lap || parseInt(input.lap, 10) < 0) {
|
||||
throw new Error('Please enter a valid lap number.');
|
||||
}
|
||||
if (!input.description.trim()) {
|
||||
throw new Error('Please describe what happened.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct file protest command from input
|
||||
*/
|
||||
constructFileProtestCommand(input: FileProtestInput): FileProtestCommandDTO {
|
||||
this.validateFileProtestInput(input);
|
||||
|
||||
const incident: ProtestIncidentDTO = {
|
||||
lap: parseInt(input.lap, 10),
|
||||
description: input.description.trim(),
|
||||
...(input.timeInRace ? { timeInRace: parseInt(input.timeInRace, 10) } : {}),
|
||||
};
|
||||
|
||||
const command: FileProtestCommandDTO = {
|
||||
raceId: input.raceId,
|
||||
protestingDriverId: input.protestingDriverId,
|
||||
accusedDriverId: input.accusedDriverId,
|
||||
incident,
|
||||
...(input.comment?.trim() ? { comment: input.comment.trim() } : {}),
|
||||
...(input.proofVideoUrl?.trim() ? { proofVideoUrl: input.proofVideoUrl.trim() } : {}),
|
||||
};
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailV
|
||||
import { RaceWithSOFViewModel } from '../../view-models/RaceWithSOFViewModel';
|
||||
import { ImportRaceResultsSummaryViewModel } from '../../view-models/ImportRaceResultsSummaryViewModel';
|
||||
import type { ImportRaceResultsDTO } from '../../types/generated/ImportRaceResultsDTO';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Define types
|
||||
type ImportRaceResultsInputDto = ImportRaceResultsDTO;
|
||||
@@ -14,6 +15,24 @@ type ImportRaceResultsSummaryDto = {
|
||||
errors?: string[];
|
||||
};
|
||||
|
||||
export interface ImportResultRowDTO {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}
|
||||
|
||||
export interface CSVRow {
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Race Results Service
|
||||
*
|
||||
@@ -48,4 +67,112 @@ export class RaceResultsService {
|
||||
const dto = await this.apiClient.importResults(raceId, input);
|
||||
return new ImportRaceResultsSummaryViewModel(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSV content and validate results
|
||||
* @throws Error with descriptive message if validation fails
|
||||
*/
|
||||
parseCSV(content: string): CSVRow[] {
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
if (lines.length < 2) {
|
||||
throw new Error('CSV file is empty or invalid');
|
||||
}
|
||||
|
||||
const headerLine = lines[0]!;
|
||||
const header = headerLine.toLowerCase().split(',').map((h) => h.trim());
|
||||
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!header.includes(field)) {
|
||||
throw new Error(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: CSVRow[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
const values = line.split(',').map((v) => v.trim());
|
||||
|
||||
if (values.length !== header.length) {
|
||||
throw new Error(
|
||||
`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const row: Record<string, string> = {};
|
||||
header.forEach((field, index) => {
|
||||
row[field] = values[index] ?? '';
|
||||
});
|
||||
|
||||
const driverId = row['driverid'] ?? '';
|
||||
const position = parseInt(row['position'] ?? '', 10);
|
||||
const fastestLap = parseFloat(row['fastestlap'] ?? '');
|
||||
const incidents = parseInt(row['incidents'] ?? '', 10);
|
||||
const startPosition = parseInt(row['startposition'] ?? '', 10);
|
||||
|
||||
if (!driverId || driverId.length === 0) {
|
||||
throw new Error(`Row ${i}: driverId is required`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(position) || position < 1) {
|
||||
throw new Error(`Row ${i}: position must be a positive integer`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(fastestLap) || fastestLap < 0) {
|
||||
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(incidents) || incidents < 0) {
|
||||
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(startPosition) || startPosition < 1) {
|
||||
throw new Error(`Row ${i}: startPosition must be a positive integer`);
|
||||
}
|
||||
|
||||
rows.push({ driverId, position, fastestLap, incidents, startPosition });
|
||||
}
|
||||
|
||||
const positions = rows.map((r) => r.position);
|
||||
const uniquePositions = new Set(positions);
|
||||
if (positions.length !== uniquePositions.size) {
|
||||
throw new Error('Duplicate positions found in CSV');
|
||||
}
|
||||
|
||||
const driverIds = rows.map((r) => r.driverId);
|
||||
const uniqueDrivers = new Set(driverIds);
|
||||
if (driverIds.length !== uniqueDrivers.size) {
|
||||
throw new Error('Duplicate driver IDs found in CSV');
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform parsed CSV rows into ImportResultRowDTO array
|
||||
*/
|
||||
transformToImportResults(rows: CSVRow[], raceId: string): ImportResultRowDTO[] {
|
||||
return rows.map((row) => ({
|
||||
id: uuidv4(),
|
||||
raceId,
|
||||
driverId: row.driverId,
|
||||
position: row.position,
|
||||
fastestLap: row.fastestLap,
|
||||
incidents: row.incidents,
|
||||
startPosition: row.startPosition,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSV file content and transform to import results
|
||||
* @throws Error with descriptive message if parsing or validation fails
|
||||
*/
|
||||
parseAndTransformCSV(content: string, raceId: string): ImportResultRowDTO[] {
|
||||
const rows = this.parseCSV(content);
|
||||
return this.transformToImportResults(rows, raceId);
|
||||
}
|
||||
}
|
||||
125
apps/website/lib/view-models/LeagueScoringSectionViewModel.ts
Normal file
125
apps/website/lib/view-models/LeagueScoringSectionViewModel.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
|
||||
import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationViewModel';
|
||||
|
||||
/**
|
||||
* LeagueScoringSectionViewModel
|
||||
*
|
||||
* View model for the league scoring section UI state and operations
|
||||
*/
|
||||
export class LeagueScoringSectionViewModel {
|
||||
readonly form: LeagueConfigFormModel;
|
||||
readonly presets: LeagueScoringPresetViewModel[];
|
||||
readonly readOnly: boolean;
|
||||
readonly patternOnly: boolean;
|
||||
readonly championshipsOnly: boolean;
|
||||
readonly disabled: boolean;
|
||||
readonly currentPreset: LeagueScoringPresetViewModel | null;
|
||||
readonly isCustom: boolean;
|
||||
|
||||
constructor(
|
||||
form: LeagueConfigFormModel,
|
||||
presets: LeagueScoringPresetViewModel[],
|
||||
options: {
|
||||
readOnly?: boolean;
|
||||
patternOnly?: boolean;
|
||||
championshipsOnly?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
this.form = form;
|
||||
this.presets = presets;
|
||||
this.readOnly = options.readOnly || false;
|
||||
this.patternOnly = options.patternOnly || false;
|
||||
this.championshipsOnly = options.championshipsOnly || false;
|
||||
this.disabled = this.readOnly;
|
||||
this.currentPreset = form.scoring.patternId
|
||||
? presets.find(p => p.id === form.scoring.patternId) || null
|
||||
: null;
|
||||
this.isCustom = form.scoring.customScoringEnabled || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default custom points configuration
|
||||
*/
|
||||
static getDefaultCustomPoints(): CustomPointsConfig {
|
||||
return {
|
||||
racePoints: [25, 18, 15, 12, 10, 8, 6, 4, 2, 1],
|
||||
poleBonusPoints: 1,
|
||||
fastestLapPoints: 1,
|
||||
leaderLapPoints: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if form can be modified
|
||||
*/
|
||||
canModify(): boolean {
|
||||
return !this.readOnly;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available presets for display
|
||||
*/
|
||||
getAvailablePresets(): LeagueScoringPresetViewModel[] {
|
||||
return this.presets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get championships configuration for display
|
||||
*/
|
||||
getChampionshipsConfig() {
|
||||
const isTeamsMode = this.form.structure.mode === 'fixedTeams';
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'enableDriverChampionship' as const,
|
||||
label: 'Driver Standings',
|
||||
description: 'Track individual driver points',
|
||||
enabled: this.form.championships.enableDriverChampionship,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
key: 'enableTeamChampionship' as const,
|
||||
label: 'Team Standings',
|
||||
description: 'Combined team points',
|
||||
enabled: this.form.championships.enableTeamChampionship,
|
||||
available: isTeamsMode,
|
||||
unavailableHint: 'Teams mode only',
|
||||
},
|
||||
{
|
||||
key: 'enableNationsChampionship' as const,
|
||||
label: 'Nations Cup',
|
||||
description: 'By nationality',
|
||||
enabled: this.form.championships.enableNationsChampionship,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
key: 'enableTrophyChampionship' as const,
|
||||
label: 'Trophy Cup',
|
||||
description: 'Special category',
|
||||
enabled: this.form.championships.enableTrophyChampionship,
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get panel visibility based on flags
|
||||
*/
|
||||
shouldShowPatternPanel(): boolean {
|
||||
return !this.championshipsOnly;
|
||||
}
|
||||
|
||||
shouldShowChampionshipsPanel(): boolean {
|
||||
return !this.patternOnly;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active custom points configuration
|
||||
*/
|
||||
getActiveCustomPoints(): CustomPointsConfig {
|
||||
// This would be stored separately in the form model
|
||||
// For now, return defaults
|
||||
return LeagueScoringSectionViewModel.getDefaultCustomPoints();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
|
||||
|
||||
export interface CustomPointsConfig {
|
||||
racePoints: number[];
|
||||
poleBonusPoints: number;
|
||||
fastestLapPoints: number;
|
||||
leaderLapPoints: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ScoringConfigurationViewModel
|
||||
*
|
||||
* View model for scoring configuration including presets and custom points
|
||||
*/
|
||||
export class ScoringConfigurationViewModel {
|
||||
readonly patternId?: string;
|
||||
readonly customScoringEnabled: boolean;
|
||||
readonly customPoints?: CustomPointsConfig;
|
||||
readonly currentPreset?: LeagueScoringPresetViewModel;
|
||||
|
||||
constructor(
|
||||
config: LeagueConfigFormModel['scoring'],
|
||||
presets: LeagueScoringPresetViewModel[],
|
||||
customPoints?: CustomPointsConfig
|
||||
) {
|
||||
this.patternId = config.patternId;
|
||||
this.customScoringEnabled = config.customScoringEnabled || false;
|
||||
this.customPoints = customPoints;
|
||||
this.currentPreset = config.patternId
|
||||
? presets.find(p => p.id === config.patternId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active points configuration
|
||||
*/
|
||||
getActivePointsConfig(): CustomPointsConfig {
|
||||
if (this.customScoringEnabled && this.customPoints) {
|
||||
return this.customPoints;
|
||||
}
|
||||
// Return default points if no custom config
|
||||
return {
|
||||
racePoints: [25, 18, 15, 12, 10, 8, 6, 4, 2, 1],
|
||||
poleBonusPoints: 1,
|
||||
fastestLapPoints: 1,
|
||||
leaderLapPoints: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ export * from './RaceDetailEntryViewModel';
|
||||
export * from './RaceDetailUserResultViewModel';
|
||||
export * from './RaceDetailViewModel';
|
||||
export * from './RaceListItemViewModel';
|
||||
export * from './RaceResultsDataTransformer';
|
||||
export * from './RaceResultsDetailViewModel';
|
||||
export * from './RaceResultViewModel';
|
||||
export * from './RacesPageViewModel';
|
||||
|
||||
Reference in New Issue
Block a user