This commit is contained in:
2025-12-11 21:06:25 +01:00
parent c49ea2598d
commit ec3ddc3a5c
227 changed files with 3496 additions and 2083 deletions

23
.eslintrc.json Normal file
View File

@@ -0,0 +1,23 @@
{
"root": true,
"env": {
"es2022": true,
"node": true
},
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 2022
},
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": [],
"rules": {
"@typescript-eslint/no-explicit-any": "error"
}
}
]
}

3
.gitignore vendored
View File

@@ -44,3 +44,6 @@ tmp/
temp/ temp/
.vercel .vercel
.env*.local .env*.local
userData/

View File

@@ -11,12 +11,14 @@ import type { AutomationEnginePort } from '@gridpilot/automation/application/por
import type { AuthenticationServicePort } from '@gridpilot/automation/application/ports/AuthenticationServicePort'; import type { AuthenticationServicePort } from '@gridpilot/automation/application/ports/AuthenticationServicePort';
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort'; import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
import type { OverlaySyncPort } from '@gridpilot/automation/application/ports/OverlaySyncPort'; import type { OverlaySyncPort } from '@gridpilot/automation/application/ports/OverlaySyncPort';
import type { CheckoutServicePort } from '@gridpilot/automation/application/ports/CheckoutServicePort';
import { StartAutomationSessionUseCase } from '@gridpilot/automation/application/use-cases/StartAutomationSessionUseCase'; import { StartAutomationSessionUseCase } from '@gridpilot/automation/application/use-cases/StartAutomationSessionUseCase';
import { CheckAuthenticationUseCase } from '@gridpilot/automation/application/use-cases/CheckAuthenticationUseCase'; import { CheckAuthenticationUseCase } from '@gridpilot/automation/application/use-cases/CheckAuthenticationUseCase';
import { InitiateLoginUseCase } from '@gridpilot/automation/application/use-cases/InitiateLoginUseCase'; import { InitiateLoginUseCase } from '@gridpilot/automation/application/use-cases/InitiateLoginUseCase';
import { ClearSessionUseCase } from '@gridpilot/automation/application/use-cases/ClearSessionUseCase'; import { ClearSessionUseCase } from '@gridpilot/automation/application/use-cases/ClearSessionUseCase';
import { ConfirmCheckoutUseCase } from '@gridpilot/automation/application/use-cases/ConfirmCheckoutUseCase'; import { ConfirmCheckoutUseCase } from '@gridpilot/automation/application/use-cases/ConfirmCheckoutUseCase';
import { OverlaySyncService } from '@gridpilot/automation/application/services/OverlaySyncService'; import { OverlaySyncService } from '@gridpilot/automation/application/services/OverlaySyncService';
import type { IAutomationLifecycleEmitter } from '@gridpilot/automation/infrastructure/adapters/IAutomationLifecycleEmitter';
// Infrastructure // Infrastructure
import { InMemorySessionRepository } from '@gridpilot/automation/infrastructure/repositories/InMemorySessionRepository'; import { InMemorySessionRepository } from '@gridpilot/automation/infrastructure/repositories/InMemorySessionRepository';
@@ -187,13 +189,19 @@ export function configureDIContainer(): void {
browserAutomation browserAutomation
); );
// Checkout Service (singleton, backed by browser automation)
container.registerInstance<CheckoutServicePort>(
DI_TOKENS.CheckoutService,
browserAutomation as unknown as CheckoutServicePort
);
// Automation Engine (singleton) // Automation Engine (singleton)
const sessionRepository = container.resolve<SessionRepositoryPort>(DI_TOKENS.SessionRepository); const sessionRepository = container.resolve<SessionRepositoryPort>(DI_TOKENS.SessionRepository);
let automationEngine: AutomationEnginePort; let automationEngine: AutomationEnginePort;
if (fixtureMode) { if (fixtureMode) {
automationEngine = new AutomationEngineAdapter( automationEngine = new AutomationEngineAdapter(
browserAutomation as any, browserAutomation,
sessionRepository sessionRepository
); );
} else { } else {
@@ -247,16 +255,16 @@ export function configureDIContainer(): void {
} }
// Overlay Sync Service - create singleton instance directly // Overlay Sync Service - create singleton instance directly
const lifecycleEmitter = browserAutomation as any; const lifecycleEmitter = browserAutomation as unknown as IAutomationLifecycleEmitter;
const publisher = { const publisher = {
publish: async (_event: any) => { publish: async (event: unknown) => {
try { try {
logger.debug?.('OverlaySyncPublisher.publish', _event); logger.debug?.('OverlaySyncPublisher.publish', { event });
} catch { } catch {
// swallow // swallow
} }
}, },
} as any; };
const overlaySyncService = new OverlaySyncService({ const overlaySyncService = new OverlaySyncService({
lifecycleEmitter, lifecycleEmitter,
publisher, publisher,

View File

@@ -13,6 +13,7 @@ import type { IBrowserAutomation } from '@gridpilot/automation/application/ports
import type { AutomationEnginePort } from '@gridpilot/automation/application/ports/AutomationEnginePort'; import type { AutomationEnginePort } from '@gridpilot/automation/application/ports/AutomationEnginePort';
import type { AuthenticationServicePort } from '@gridpilot/automation/application/ports/AuthenticationServicePort'; import type { AuthenticationServicePort } from '@gridpilot/automation/application/ports/AuthenticationServicePort';
import type { CheckoutConfirmationPort } from '@gridpilot/automation/application/ports/CheckoutConfirmationPort'; import type { CheckoutConfirmationPort } from '@gridpilot/automation/application/ports/CheckoutConfirmationPort';
import type { CheckoutServicePort } from '@gridpilot/automation/application/ports/CheckoutServicePort';
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort'; import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
import type { OverlaySyncPort } from '@gridpilot/automation/application/ports/OverlaySyncPort'; import type { OverlaySyncPort } from '@gridpilot/automation/application/ports/OverlaySyncPort';
@@ -145,9 +146,9 @@ export class DIContainer {
public setConfirmCheckoutUseCase(checkoutConfirmationPort: CheckoutConfirmationPort): void { public setConfirmCheckoutUseCase(checkoutConfirmationPort: CheckoutConfirmationPort): void {
this.ensureInitialized(); this.ensureInitialized();
const browserAutomation = getDIContainer().resolve<IBrowserAutomation>(DI_TOKENS.BrowserAutomation); const checkoutService = getDIContainer().resolve<CheckoutServicePort>(DI_TOKENS.CheckoutService);
this.confirmCheckoutUseCase = new ConfirmCheckoutUseCase( this.confirmCheckoutUseCase = new ConfirmCheckoutUseCase(
browserAutomation as any, checkoutService,
checkoutConfirmationPort checkoutConfirmationPort
); );
} }
@@ -188,7 +189,7 @@ export class DIContainer {
new Error(result.error || 'Unknown error'), new Error(result.error || 'Unknown error'),
{ mode: this.automationMode } { mode: this.automationMode }
); );
return { success: false, error: result.error }; return { success: false, error: result.error ?? 'Unknown error' };
} }
const isConnected = playwrightAdapter.isConnected(); const isConnected = playwrightAdapter.isConnected();

View File

@@ -27,6 +27,7 @@ export const DI_TOKENS = {
// Services // Services
OverlaySyncPort: Symbol.for('OverlaySyncPort'), OverlaySyncPort: Symbol.for('OverlaySyncPort'),
CheckoutService: Symbol.for('CheckoutServicePort'),
// Infrastructure // Infrastructure
FixtureServer: Symbol.for('FixtureServer'), FixtureServer: Symbol.for('FixtureServer'),

View File

@@ -1,10 +1,12 @@
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'; import type { BrowserWindow, IpcMainInvokeEvent } from 'electron';
import { DIContainer } from './di-container'; import { DIContainer } from './di-container';
import type { HostedSessionConfig } from '@/packages/domain/entities/HostedSessionConfig'; import type { HostedSessionConfig } from 'packages/automation/domain/types/HostedSessionConfig';
import { StepId } from '@/packages/domain/value-objects/StepId'; import { StepId } from 'packages/automation/domain/value-objects/StepId';
import { AuthenticationState } from '@/packages/domain/value-objects/AuthenticationState'; import { AuthenticationState } from 'packages/automation/domain/value-objects/AuthenticationState';
import { ElectronCheckoutConfirmationAdapter } from '@/packages/infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter'; import { ElectronCheckoutConfirmationAdapter } from 'packages/automation/infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter';
import type { OverlayAction } from 'packages/automation/application/ports/OverlaySyncPort';
import type { IAutomationLifecycleEmitter } from 'packages/automation/infrastructure/adapters/IAutomationLifecycleEmitter';
let progressMonitorInterval: NodeJS.Timeout | null = null; let progressMonitorInterval: NodeJS.Timeout | null = null;
let lifecycleSubscribed = false; let lifecycleSubscribed = false;
@@ -95,8 +97,8 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
} }
// Call confirmLoginComplete on the adapter if it exists // Call confirmLoginComplete on the adapter if it exists
if ('confirmLoginComplete' in authService) { if ('confirmLoginComplete' in authService && typeof authService.confirmLoginComplete === 'function') {
const result = await (authService as any).confirmLoginComplete(); const result = await authService.confirmLoginComplete();
if (result.isErr()) { if (result.isErr()) {
logger.error('Confirm login failed', result.unwrapErr()); logger.error('Confirm login failed', result.unwrapErr());
return { success: false, error: result.unwrapErr().message }; return { success: false, error: result.unwrapErr().message };
@@ -334,7 +336,7 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
// Ensure runtime automation wiring reflects the new browser mode // Ensure runtime automation wiring reflects the new browser mode
if ('refreshBrowserAutomation' in container) { if ('refreshBrowserAutomation' in container) {
// Call method to refresh adapters/use-cases that depend on browser mode // Call method to refresh adapters/use-cases that depend on browser mode
(container as any).refreshBrowserAutomation(); container.refreshBrowserAutomation();
} }
logger.info('Browser mode updated', { mode }); logger.info('Browser mode updated', { mode });
return { success: true, mode }; return { success: true, mode };
@@ -349,9 +351,9 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
}); });
// Handle overlay action requests from renderer and forward to the OverlaySyncService // Handle overlay action requests from renderer and forward to the OverlaySyncService
ipcMain.handle('overlay-action-request', async (_event: IpcMainInvokeEvent, action: any) => { ipcMain.handle('overlay-action-request', async (_event: IpcMainInvokeEvent, action: OverlayAction) => {
try { try {
const overlayPort = (container as any).getOverlaySyncPort ? container.getOverlaySyncPort() : null; const overlayPort = 'getOverlaySyncPort' in container ? container.getOverlaySyncPort() : null;
if (!overlayPort) { if (!overlayPort) {
logger.warn('OverlaySyncPort not available'); logger.warn('OverlaySyncPort not available');
return { id: action?.id ?? 'unknown', status: 'failed', reason: 'OverlaySyncPort not available' }; return { id: action?.id ?? 'unknown', status: 'failed', reason: 'OverlaySyncPort not available' };
@@ -361,16 +363,20 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
} catch (e) { } catch (e) {
const err = e instanceof Error ? e : new Error(String(e)); const err = e instanceof Error ? e : new Error(String(e));
logger.error('Overlay action request failed', err); logger.error('Overlay action request failed', err);
return { id: action?.id ?? 'unknown', status: 'failed', reason: err.message }; const id = typeof action === 'object' && action !== null && 'id' in action
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(action as { id?: string }).id ?? 'unknown'
: 'unknown';
return { id, status: 'failed', reason: err.message };
} }
}); });
// Subscribe to automation adapter lifecycle events and relay to renderer // Subscribe to automation adapter lifecycle events and relay to renderer
try { try {
if (!lifecycleSubscribed) { if (!lifecycleSubscribed) {
const browserAutomation = container.getBrowserAutomation() as any; const lifecycleEmitter = container.getBrowserAutomation() as unknown as IAutomationLifecycleEmitter;
if (browserAutomation && typeof browserAutomation.onLifecycle === 'function') { if (typeof lifecycleEmitter.onLifecycle === 'function') {
browserAutomation.onLifecycle((ev: any) => { lifecycleEmitter.onLifecycle((ev) => {
try { try {
if (mainWindow && mainWindow.webContents) { if (mainWindow && mainWindow.webContents) {
mainWindow.webContents.send('automation-event', ev); mainWindow.webContents.send('automation-event', ev);

View File

@@ -1,6 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import type { HostedSessionConfig } from '../../../packages/automation/domain/types/HostedSessionConfig'; import type { HostedSessionConfig } from '../../../packages/automation/domain/types/HostedSessionConfig';
import type { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState'; import type { AuthenticationState } from '../../../packages/automation/domain/value-objects/AuthenticationState';
export interface AuthStatusEvent { export interface AuthStatusEvent {
state: AuthenticationState; state: AuthenticationState;

View File

@@ -5,7 +5,7 @@ import { LoginPrompt } from './components/LoginPrompt';
import { BrowserModeToggle } from './components/BrowserModeToggle'; import { BrowserModeToggle } from './components/BrowserModeToggle';
import { CheckoutConfirmationDialog } from './components/CheckoutConfirmationDialog'; import { CheckoutConfirmationDialog } from './components/CheckoutConfirmationDialog';
import { RaceCreationSuccessScreen } from './components/RaceCreationSuccessScreen'; import { RaceCreationSuccessScreen } from './components/RaceCreationSuccessScreen';
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig'; import type { HostedSessionConfig } from '../../../packages/automation/domain/types/HostedSessionConfig';
interface SessionProgress { interface SessionProgress {
sessionId: string; sessionId: string;
@@ -138,7 +138,13 @@ export function App() {
const handleStartAutomation = async (config: HostedSessionConfig) => { const handleStartAutomation = async (config: HostedSessionConfig) => {
setIsRunning(true); setIsRunning(true);
const result = await window.electronAPI.startAutomation(config); const result = await window.electronAPI.startAutomation(config) as {
success: boolean;
sessionId?: string;
error?: string;
authRequired?: boolean;
authState?: AuthState;
};
if (result.success && result.sessionId) { if (result.success && result.sessionId) {
setSessionId(result.sessionId); setSessionId(result.sessionId);
@@ -147,8 +153,8 @@ export function App() {
setIsRunning(false); setIsRunning(false);
if ((result as any).authRequired) { if ('authRequired' in result && result.authRequired) {
const nextAuthState = (result as any).authState as AuthState | undefined; const nextAuthState = result.authState as AuthState | undefined;
setAuthState(nextAuthState ?? 'EXPIRED'); setAuthState(nextAuthState ?? 'EXPIRED');
setAuthError(result.error ?? 'Authentication required before starting automation.'); setAuthError(result.error ?? 'Authentication required before starting automation.');
return; return;

View File

@@ -4,7 +4,7 @@ type LoginStatus = 'idle' | 'waiting' | 'success' | 'error';
interface LoginPromptProps { interface LoginPromptProps {
authState: string; authState: string;
errorMessage?: string; errorMessage: string | undefined;
onLogin: () => void; onLogin: () => void;
onRetry: () => void; onRetry: () => void;
loginStatus?: LoginStatus; loginStatus?: LoginStatus;

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { HostedSessionConfig } from '../../../../packages/domain/entities/HostedSessionConfig'; import type { HostedSessionConfig } from '../../../../packages/automation/domain/types/HostedSessionConfig';
interface SessionCreationFormProps { interface SessionCreationFormProps {
onSubmit: (config: HostedSessionConfig) => void; onSubmit: (config: HostedSessionConfig) => void;
@@ -112,7 +112,14 @@ export function SessionCreationForm({ onSubmit, disabled }: SessionCreationFormP
<label style={labelStyle}>Weather Type</label> <label style={labelStyle}>Weather Type</label>
<select <select
value={config.weatherType} value={config.weatherType}
onChange={(e) => setConfig({ ...config, weatherType: e.target.value as any })} onChange={(e) =>
setConfig(prev =>
({
...prev,
weatherType: e.target.value as HostedSessionConfig['weatherType'],
} as HostedSessionConfig)
)
}
style={inputStyle} style={inputStyle}
disabled={disabled} disabled={disabled}
> >
@@ -125,7 +132,14 @@ export function SessionCreationForm({ onSubmit, disabled }: SessionCreationFormP
<label style={labelStyle}>Time of Day</label> <label style={labelStyle}>Time of Day</label>
<select <select
value={config.timeOfDay} value={config.timeOfDay}
onChange={(e) => setConfig({ ...config, timeOfDay: e.target.value as any })} onChange={(e) =>
setConfig(prev =>
({
...prev,
timeOfDay: e.target.value as HostedSessionConfig['timeOfDay'],
} as HostedSessionConfig)
)
}
style={inputStyle} style={inputStyle}
disabled={disabled} disabled={disabled}
> >

View File

@@ -73,8 +73,8 @@ export function SessionProgressMonitor({ sessionId, progress, isRunning }: Sessi
(async () => { (async () => {
try { try {
// Use electronAPI overlayActionRequest to obtain ack // Use electronAPI overlayActionRequest to obtain ack
if ((window as any).electronAPI?.overlayActionRequest) { if (window.electronAPI?.overlayActionRequest) {
const ack = await (window as any).electronAPI.overlayActionRequest(action); const ack = await window.electronAPI.overlayActionRequest(action);
if (!mounted) return; if (!mounted) return;
setAckStatusByStep(prev => ({ ...prev, [currentStep]: ack.status })); setAckStatusByStep(prev => ({ ...prev, [currentStep]: ack.status }));
} else { } else {
@@ -91,8 +91,8 @@ export function SessionProgressMonitor({ sessionId, progress, isRunning }: Sessi
// Subscribe to automation events for optional live updates // Subscribe to automation events for optional live updates
useEffect(() => { useEffect(() => {
if ((window as any).electronAPI?.onAutomationEvent) { if (window.electronAPI?.onAutomationEvent) {
const off = (window as any).electronAPI.onAutomationEvent((ev: any) => { const off = window.electronAPI.onAutomationEvent((ev) => {
if (ev && ev.payload && ev.payload.actionId && ev.type) { if (ev && ev.payload && ev.payload.actionId && ev.type) {
setAutomationEventMsg(`${ev.type} ${ev.payload.actionId}`); setAutomationEventMsg(`${ev.type} ${ev.payload.actionId}`);
} else if (ev && ev.type) { } else if (ev && ev.type) {

View File

@@ -3,6 +3,7 @@
"rules": { "rules": {
"react/no-unescaped-entities": "off", "react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn", "@next/next/no-img-element": "warn",
"react-hooks/exhaustive-deps": "warn" "react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-explicit-any": "error"
} }
} }

View File

@@ -27,7 +27,10 @@ export async function POST(request: Request) {
return jsonError(400, 'Invalid request body'); return jsonError(400, 'Invalid request body');
} }
const email = (body as any)?.email; const email =
typeof body === 'object' && body !== null && 'email' in body
? (body as { email: unknown }).email
: undefined;
if (typeof email !== 'string' || !email.trim()) { if (typeof email !== 'string' || !email.trim()) {
return jsonError(400, 'Invalid email address'); return jsonError(400, 'Invalid email address');

View File

@@ -9,7 +9,8 @@ export async function GET(request: Request) {
const url = new URL(request.url); const url = new URL(request.url);
const code = url.searchParams.get('code') ?? undefined; const code = url.searchParams.get('code') ?? undefined;
const state = url.searchParams.get('state') ?? undefined; const state = url.searchParams.get('state') ?? undefined;
const returnTo = url.searchParams.get('returnTo') ?? undefined; const rawReturnTo = url.searchParams.get('returnTo');
const returnTo = rawReturnTo ?? undefined;
if (!code || !state) { if (!code || !state) {
return NextResponse.redirect('/auth/iracing'); return NextResponse.redirect('/auth/iracing');
@@ -23,7 +24,8 @@ export async function GET(request: Request) {
} }
const authService = getAuthService(); const authService = getAuthService();
await authService.loginWithIracingCallback({ code, state, returnTo }); const loginInput = returnTo ? { code, state, returnTo } : { code, state };
await authService.loginWithIracingCallback(loginInput);
cookieStore.delete(STATE_COOKIE); cookieStore.delete(STATE_COOKIE);

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, use } from 'react'; import { useState, useEffect } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
@@ -43,12 +43,16 @@ import {
getGetAllTeamsUseCase, getGetAllTeamsUseCase,
getGetTeamMembersUseCase, getGetTeamMembersUseCase,
} from '@/lib/di-container'; } from '@/lib/di-container';
import { AllTeamsPresenter } from '@/lib/presenters/AllTeamsPresenter';
import { TeamMembersPresenter } from '@/lib/presenters/TeamMembersPresenter';
import { Driver, EntityMappers, type Team } from '@gridpilot/racing'; import { Driver, EntityMappers, type Team } from '@gridpilot/racing';
import type { DriverDTO } from '@gridpilot/racing'; import type { DriverDTO } from '@gridpilot/racing';
import type { ProfileOverviewViewModel } from '@gridpilot/racing/application/presenters/IProfileOverviewPresenter';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Breadcrumbs from '@/components/layout/Breadcrumbs'; import Breadcrumbs from '@/components/layout/Breadcrumbs';
import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO'; import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO';
import type { TeamMemberViewModel } from '@gridpilot/racing/application/presenters/ITeamMembersPresenter';
// ============================================================================ // ============================================================================
// TYPES // TYPES
@@ -134,14 +138,22 @@ function getDemoExtendedProfile(driverId: string): DriverExtendedProfile {
const timezones = ['EST (UTC-5)', 'CET (UTC+1)', 'PST (UTC-8)', 'GMT (UTC+0)', 'JST (UTC+9)']; const timezones = ['EST (UTC-5)', 'CET (UTC+1)', 'PST (UTC-8)', 'GMT (UTC+0)', 'JST (UTC+9)'];
const hours = ['Evenings (18:00-23:00)', 'Weekends only', 'Late nights (22:00-02:00)', 'Flexible schedule']; const hours = ['Evenings (18:00-23:00)', 'Weekends only', 'Late nights (22:00-02:00)', 'Flexible schedule'];
const socialHandles = socialOptions[hash % socialOptions.length] ?? [];
const achievements = achievementSets[hash % achievementSets.length] ?? [];
const racingStyle = styles[hash % styles.length] ?? 'Consistent Pacer';
const favoriteTrack = tracks[hash % tracks.length] ?? 'Unknown Track';
const favoriteCar = cars[hash % cars.length] ?? 'Unknown Car';
const timezone = timezones[hash % timezones.length] ?? 'UTC';
const availableHours = hours[hash % hours.length] ?? 'Flexible schedule';
return { return {
socialHandles: socialOptions[hash % socialOptions.length], socialHandles,
achievements: achievementSets[hash % achievementSets.length], achievements,
racingStyle: styles[hash % styles.length], racingStyle,
favoriteTrack: tracks[hash % tracks.length], favoriteTrack,
favoriteCar: cars[hash % cars.length], favoriteCar,
timezone: timezones[hash % timezones.length], timezone,
availableHours: hours[hash % hours.length], availableHours,
lookingForTeam: hash % 3 === 0, lookingForTeam: hash % 3 === 0,
openToRequests: hash % 2 === 0, openToRequests: hash % 2 === 0,
}; };
@@ -301,11 +313,46 @@ function HorizontalBarChart({ data, maxValue }: BarChartProps) {
// MAIN PAGE // MAIN PAGE
// ============================================================================ // ============================================================================
export default function DriverDetailPage({ interface DriverProfileStatsViewModel {
searchParams, rating: number;
}: { wins: number;
searchParams: any; podiums: number;
}) { dnfs: number;
totalRaces: number;
avgFinish: number;
bestFinish: number;
worstFinish: number;
consistency: number;
percentile: number;
}
interface DriverProfileFriendViewModel {
id: string;
name: string;
country: string;
}
interface DriverProfileExtendedViewModel extends DriverExtendedProfile {}
interface DriverProfileViewModel {
currentDriver?: {
id: string;
name: string;
iracingId?: string | null;
country: string;
bio?: string | null;
joinedAt: string | Date;
globalRank?: number;
totalDrivers?: number;
};
stats?: DriverProfileStatsViewModel;
extendedProfile?: DriverProfileExtendedViewModel;
socialSummary?: {
friends: DriverProfileFriendViewModel[];
};
}
export default function DriverDetailPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const driverId = params.id as string; const driverId = params.id as string;
@@ -318,24 +365,16 @@ export default function DriverDetailPage({
const [allTeamMemberships, setAllTeamMemberships] = useState<TeamMembershipInfo[]>([]); const [allTeamMemberships, setAllTeamMemberships] = useState<TeamMembershipInfo[]>([]);
const [friends, setFriends] = useState<Driver[]>([]); const [friends, setFriends] = useState<Driver[]>([]);
const [friendRequestSent, setFriendRequestSent] = useState(false); const [friendRequestSent, setFriendRequestSent] = useState(false);
const [profileData, setProfileData] = useState<any>(null); const [profileData, setProfileData] = useState<ProfileOverviewViewModel | null>(null);
const unwrappedSearchParams = use(searchParams) as URLSearchParams | undefined; const search =
typeof window !== 'undefined'
const from = ? new URLSearchParams(window.location.search)
typeof unwrappedSearchParams?.get === 'function'
? unwrappedSearchParams.get('from') ?? undefined
: undefined; : undefined;
const leagueId = const from = search?.get('from') ?? undefined;
typeof unwrappedSearchParams?.get === 'function' const leagueId = search?.get('leagueId') ?? undefined;
? unwrappedSearchParams.get('leagueId') ?? undefined const raceId = search?.get('raceId') ?? undefined;
: undefined;
const raceId =
typeof unwrappedSearchParams?.get === 'function'
? unwrappedSearchParams.get('raceId') ?? undefined
: undefined;
let backLink: string | null = null; let backLink: string | null = null;
@@ -362,8 +401,7 @@ export default function DriverDetailPage({
try { try {
// Use GetProfileOverviewUseCase to load all profile data // Use GetProfileOverviewUseCase to load all profile data
const profileUseCase = getGetProfileOverviewUseCase(); const profileUseCase = getGetProfileOverviewUseCase();
await profileUseCase.execute({ driverId }); const profileViewModel = await profileUseCase.execute({ driverId });
const profileViewModel = profileUseCase.presenter.getViewModel();
if (!profileViewModel || !profileViewModel.currentDriver) { if (!profileViewModel || !profileViewModel.currentDriver) {
setError('Driver not found'); setError('Driver not found');
@@ -375,7 +413,7 @@ export default function DriverDetailPage({
const driverData: DriverDTO = { const driverData: DriverDTO = {
id: profileViewModel.currentDriver.id, id: profileViewModel.currentDriver.id,
name: profileViewModel.currentDriver.name, name: profileViewModel.currentDriver.name,
iracingId: profileViewModel.currentDriver.iracingId, iracingId: profileViewModel.currentDriver.iracingId ?? '',
country: profileViewModel.currentDriver.country, country: profileViewModel.currentDriver.country,
bio: profileViewModel.currentDriver.bio || '', bio: profileViewModel.currentDriver.bio || '',
joinedAt: profileViewModel.currentDriver.joinedAt, joinedAt: profileViewModel.currentDriver.joinedAt,
@@ -383,30 +421,37 @@ export default function DriverDetailPage({
setDriver(driverData); setDriver(driverData);
setProfileData(profileViewModel); setProfileData(profileViewModel);
// Load team data // Load ALL team memberships using caller-owned presenters
const teamUseCase = getGetDriverTeamUseCase();
await teamUseCase.execute({ driverId });
const teamViewModel = teamUseCase.presenter.getViewModel();
setTeamData(teamViewModel.result);
// Load ALL team memberships
const allTeamsUseCase = getGetAllTeamsUseCase(); const allTeamsUseCase = getGetAllTeamsUseCase();
await allTeamsUseCase.execute(); const allTeamsPresenter = new AllTeamsPresenter();
const allTeamsViewModel = allTeamsUseCase.presenter.getViewModel(); await allTeamsUseCase.execute(undefined as void, allTeamsPresenter);
const allTeams = allTeamsViewModel.teams; const allTeamsViewModel = allTeamsPresenter.getViewModel();
const membershipsUseCase = getGetTeamMembersUseCase(); const allTeams = allTeamsViewModel?.teams ?? [];
const membershipsUseCase = getGetTeamMembersUseCase();
const memberships: TeamMembershipInfo[] = []; const memberships: TeamMembershipInfo[] = [];
for (const team of allTeams) { for (const team of allTeams) {
await membershipsUseCase.execute({ teamId: team.id }); const teamMembersPresenter = new TeamMembersPresenter();
const membersViewModel = membershipsUseCase.presenter.getViewModel(); await membershipsUseCase.execute({ teamId: team.id }, teamMembersPresenter);
const members = membersViewModel.members; const membersResult = teamMembersPresenter.getViewModel();
const membership = members.find((m) => m.driverId === driverId); const members = membersResult?.members ?? [];
const membership = members.find(
(member: TeamMemberViewModel) => member.driverId === driverId,
);
if (membership) { if (membership) {
memberships.push({ memberships.push({
team, team: {
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
ownerId: '',
leagues: team.leagues,
createdAt: new Date(),
} as Team,
role: membership.role, role: membership.role,
joinedAt: membership.joinedAt, joinedAt: new Date(membership.joinedAt),
}); });
} }
} }
@@ -459,7 +504,30 @@ export default function DriverDetailPage({
); );
} }
const extendedProfile = profileData?.extendedProfile || getDemoExtendedProfile(driver.id); const demoExtended = getDemoExtendedProfile(driver.id);
const extendedProfile: DriverExtendedProfile = {
socialHandles: profileData?.extendedProfile?.socialHandles ?? demoExtended.socialHandles,
achievements:
profileData?.extendedProfile?.achievements
? profileData.extendedProfile.achievements.map((achievement) => ({
id: achievement.id,
title: achievement.title,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
earnedAt: new Date(achievement.earnedAt),
}))
: demoExtended.achievements,
racingStyle: profileData?.extendedProfile?.racingStyle ?? demoExtended.racingStyle,
favoriteTrack: profileData?.extendedProfile?.favoriteTrack ?? demoExtended.favoriteTrack,
favoriteCar: profileData?.extendedProfile?.favoriteCar ?? demoExtended.favoriteCar,
timezone: profileData?.extendedProfile?.timezone ?? demoExtended.timezone,
availableHours: profileData?.extendedProfile?.availableHours ?? demoExtended.availableHours,
lookingForTeam:
profileData?.extendedProfile?.lookingForTeam ?? demoExtended.lookingForTeam,
openToRequests:
profileData?.extendedProfile?.openToRequests ?? demoExtended.openToRequests,
};
const stats = profileData?.stats || null; const stats = profileData?.stats || null;
const globalRank = profileData?.currentDriver?.globalRank || 1; const globalRank = profileData?.currentDriver?.globalRank || 1;
@@ -627,7 +695,7 @@ export default function DriverDetailPage({
<div className="mt-6 pt-6 border-t border-charcoal-outline/50"> <div className="mt-6 pt-6 border-t border-charcoal-outline/50">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-500 mr-2">Connect:</span> <span className="text-sm text-gray-500 mr-2">Connect:</span>
{extendedProfile.socialHandles.map((social) => { {extendedProfile.socialHandles.map((social: SocialHandle) => {
const Icon = getSocialIcon(social.platform); const Icon = getSocialIcon(social.platform);
return ( return (
<a <a
@@ -724,7 +792,7 @@ export default function DriverDetailPage({
</div> </div>
<div className="flex gap-6"> <div className="flex gap-6">
<CircularProgress <CircularProgress
value={stats.consistency} value={stats.consistency ?? 0}
max={100} max={100}
label="Consistency" label="Consistency"
color="text-primary-blue" color="text-primary-blue"
@@ -766,7 +834,9 @@ export default function DriverDetailPage({
<Target className="w-4 h-4 text-primary-blue" /> <Target className="w-4 h-4 text-primary-blue" />
<span className="text-xs text-gray-500 uppercase">Avg Finish</span> <span className="text-xs text-gray-500 uppercase">Avg Finish</span>
</div> </div>
<p className="text-2xl font-bold text-primary-blue">P{stats.avgFinish.toFixed(1)}</p> <p className="text-2xl font-bold text-primary-blue">
P{(stats.avgFinish ?? 0).toFixed(1)}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -888,7 +958,7 @@ export default function DriverDetailPage({
<span className="ml-auto text-sm text-gray-500">{extendedProfile.achievements.length} earned</span> <span className="ml-auto text-sm text-gray-500">{extendedProfile.achievements.length} earned</span>
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{extendedProfile.achievements.map((achievement) => { {extendedProfile.achievements.map((achievement: Achievement) => {
const Icon = getAchievementIcon(achievement.icon); const Icon = getAchievementIcon(achievement.icon);
const rarityClasses = getRarityColor(achievement.rarity); const rarityClasses = getRarityColor(achievement.rarity);
return ( return (
@@ -1033,7 +1103,9 @@ export default function DriverDetailPage({
<div className="text-xs text-gray-400 uppercase">Best Finish</div> <div className="text-xs text-gray-400 uppercase">Best Finish</div>
</div> </div>
<div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center"> <div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
<div className="text-4xl font-bold text-primary-blue mb-1">P{stats.avgFinish.toFixed(1)}</div> <div className="text-4xl font-bold text-primary-blue mb-1">
P{(stats.avgFinish ?? 0).toFixed(1)}
</div>
<div className="text-xs text-gray-400 uppercase">Avg Finish</div> <div className="text-xs text-gray-400 uppercase">Avg Finish</div>
</div> </div>
<div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center"> <div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center">

View File

@@ -71,11 +71,15 @@ interface TopThreePodiumProps {
} }
function TopThreePodium({ drivers, onDriverClick }: TopThreePodiumProps) { function TopThreePodium({ drivers, onDriverClick }: TopThreePodiumProps) {
const top3 = drivers.slice(0, 3); if (drivers.length < 3) return null;
if (top3.length < 3) return null; const top3 = drivers.slice(0, 3) as [DriverListItem, DriverListItem, DriverListItem];
const podiumOrder = [top3[1], top3[0], top3[2]]; // 2nd, 1st, 3rd const podiumOrder: [DriverListItem, DriverListItem, DriverListItem] = [
top3[1],
top3[0],
top3[2],
]; // 2nd, 1st, 3rd
const podiumHeights = ['h-32', 'h-40', 'h-24']; const podiumHeights = ['h-32', 'h-40', 'h-24'];
const podiumColors = [ const podiumColors = [
'from-gray-400/20 to-gray-500/10 border-gray-400/40', 'from-gray-400/20 to-gray-500/10 border-gray-400/40',

View File

@@ -21,6 +21,7 @@ import {
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading'; import Heading from '@/components/ui/Heading';
import { getGetDriversLeaderboardUseCase, getGetTeamsLeaderboardUseCase } from '@/lib/di-container'; import { getGetDriversLeaderboardUseCase, getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
import { TeamsLeaderboardPresenter } from '@/lib/presenters/TeamsLeaderboardPresenter';
import type { DriverLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/IDriversLeaderboardPresenter'; import type { DriverLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/IDriversLeaderboardPresenter';
import type { TeamLeaderboardItemViewModel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter'; import type { TeamLeaderboardItemViewModel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
import Image from 'next/image'; import Image from 'next/image';
@@ -286,14 +287,16 @@ export default function LeaderboardsPage() {
try { try {
const driversUseCase = getGetDriversLeaderboardUseCase(); const driversUseCase = getGetDriversLeaderboardUseCase();
const teamsUseCase = getGetTeamsLeaderboardUseCase(); const teamsUseCase = getGetTeamsLeaderboardUseCase();
const teamsPresenter = new TeamsLeaderboardPresenter();
await driversUseCase.execute(); await driversUseCase.execute();
await teamsUseCase.execute(); await teamsUseCase.execute(undefined as void, teamsPresenter);
const driversViewModel = driversUseCase.presenter.getViewModel(); const driversViewModel = driversUseCase.presenter.getViewModel();
const teamsViewModel = teamsUseCase.presenter.getViewModel(); const teamsViewModel = teamsPresenter.getViewModel();
setDrivers(driversViewModel.drivers); setDrivers(driversViewModel.drivers);
setTeams(teamsViewModel.teams); setTeams(teamsViewModel ? teamsViewModel.teams : []);
} catch (error) { } catch (error) {
console.error('Failed to load leaderboard data:', error); console.error('Failed to load leaderboard data:', error);
setDrivers([]); setDrivers([]);

View File

@@ -19,8 +19,8 @@ import type { League } from '@gridpilot/racing/domain/entities/League';
// Main sponsor info for "by XYZ" display // Main sponsor info for "by XYZ" display
interface MainSponsorInfo { interface MainSponsorInfo {
name: string; name: string;
logoUrl?: string; logoUrl: string;
websiteUrl?: string; websiteUrl: string;
} }
export default function LeagueLayout({ export default function LeagueLayout({
@@ -80,8 +80,8 @@ export default function LeagueLayout({
if (sponsor) { if (sponsor) {
setMainSponsor({ setMainSponsor({
name: sponsor.name, name: sponsor.name,
logoUrl: sponsor.logoUrl, logoUrl: sponsor.logoUrl ?? '',
websiteUrl: sponsor.websiteUrl, websiteUrl: sponsor.websiteUrl ?? '',
}); });
} }
} }

View File

@@ -127,7 +127,7 @@ export default function LeagueDetailPage() {
const getLeagueScoringConfigUseCase = getGetLeagueScoringConfigUseCase(); const getLeagueScoringConfigUseCase = getGetLeagueScoringConfigUseCase();
await getLeagueScoringConfigUseCase.execute({ leagueId }); await getLeagueScoringConfigUseCase.execute({ leagueId });
const scoringViewModel = getLeagueScoringConfigUseCase.presenter.getViewModel(); const scoringViewModel = getLeagueScoringConfigUseCase.presenter.getViewModel();
setScoringConfig(scoringViewModel); setScoringConfig(scoringViewModel as unknown as LeagueScoringConfigDTO);
// Load all drivers for standings and map to DTOs for UI components // Load all drivers for standings and map to DTOs for UI components
const allDrivers = await driverRepo.findAll(); const allDrivers = await driverRepo.findAll();
@@ -157,23 +157,23 @@ export default function LeagueDetailPage() {
if (activeSeason) { if (activeSeason) {
const sponsorships = await sponsorshipRepo.findBySeasonId(activeSeason.id); const sponsorships = await sponsorshipRepo.findBySeasonId(activeSeason.id);
const activeSponsorships = sponsorships.filter(s => s.status === 'active'); const activeSponsorships = sponsorships.filter((s) => s.status === 'active');
const sponsorInfos: SponsorInfo[] = []; const sponsorInfos: SponsorInfo[] = [];
for (const sponsorship of activeSponsorships) { for (const sponsorship of activeSponsorships) {
const sponsor = await sponsorRepo.findById(sponsorship.sponsorId); const sponsor = await sponsorRepo.findById(sponsorship.sponsorId);
if (sponsor) { if (sponsor) {
// Get tagline from demo data if available const testingSupportModule = await import('@gridpilot/testing-support');
const demoSponsors = (await import('@gridpilot/testing-support')).sponsors; const demoSponsors = testingSupportModule.sponsors as Array<{ id: string; tagline?: string }>;
const demoSponsor = demoSponsors.find((s: any) => s.id === sponsor.id); const demoSponsor = demoSponsors.find((demo) => demo.id === sponsor.id);
sponsorInfos.push({ sponsorInfos.push({
id: sponsor.id, id: sponsor.id,
name: sponsor.name, name: sponsor.name,
logoUrl: sponsor.logoUrl, logoUrl: sponsor.logoUrl ?? '',
websiteUrl: sponsor.websiteUrl, websiteUrl: sponsor.websiteUrl ?? '',
tier: sponsorship.tier, tier: sponsorship.tier,
tagline: demoSponsor?.tagline, tagline: demoSponsor?.tagline ?? '',
}); });
} }
} }

View File

@@ -37,7 +37,7 @@ export default function LeagueRulebookPage() {
await scoringUseCase.execute({ leagueId }); await scoringUseCase.execute({ leagueId });
const scoringViewModel = scoringUseCase.presenter.getViewModel(); const scoringViewModel = scoringUseCase.presenter.getViewModel();
setScoringConfig(scoringViewModel); setScoringConfig(scoringViewModel as unknown as LeagueScoringConfigDTO);
} catch (err) { } catch (err) {
console.error('Failed to load scoring config:', err); console.error('Failed to load scoring config:', err);
} finally { } finally {

View File

@@ -14,6 +14,8 @@ import {
getListLeagueScoringPresetsUseCase, getListLeagueScoringPresetsUseCase,
getTransferLeagueOwnershipUseCase getTransferLeagueOwnershipUseCase
} from '@/lib/di-container'; } from '@/lib/di-container';
import { LeagueFullConfigPresenter } from '@/lib/presenters/LeagueFullConfigPresenter';
import { LeagueScoringPresetsPresenter } from '@/lib/presenters/LeagueScoringPresetsPresenter';
import { useEffectiveDriverId } from '@/lib/currentDriver'; import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import { ScoringPatternSection, ChampionshipsSection } from '@/components/leagues/LeagueScoringSection'; import { ScoringPatternSection, ChampionshipsSection } from '@/components/leagues/LeagueScoringSection';
@@ -70,13 +72,17 @@ export default function LeagueSettingsPage() {
setLeague(leagueData); setLeague(leagueData);
await useCase.execute({ leagueId }); const configPresenter = new LeagueFullConfigPresenter();
const configViewModel = useCase.presenter.getViewModel(); await useCase.execute({ leagueId }, configPresenter);
setConfigForm(configViewModel); const configViewModel = configPresenter.getViewModel();
if (configViewModel) {
setConfigForm(configViewModel as LeagueConfigFormModel);
}
await presetsUseCase.execute(); const presetsPresenter = new LeagueScoringPresetsPresenter();
const presetsViewModel = presetsUseCase.presenter.getViewModel(); await presetsUseCase.execute(undefined as void, presetsPresenter);
setPresets(presetsViewModel); const presetsViewModel = presetsPresenter.getViewModel();
setPresets(presetsViewModel.presets);
const entity = await driverRepo.findById(leagueData.ownerId); const entity = await driverRepo.findById(leagueData.ownerId);
if (entity) { if (entity) {

View File

@@ -37,8 +37,15 @@ export default function LeagueStandingsPage() {
const membershipRepo = getLeagueMembershipRepository(); const membershipRepo = getLeagueMembershipRepository();
await getLeagueDriverSeasonStatsUseCase.execute({ leagueId }); await getLeagueDriverSeasonStatsUseCase.execute({ leagueId });
const standingsViewModel = getLeagueDriverSeasonStatsUseCase.presenter.getViewModel(); type GetLeagueDriverSeasonStatsUseCaseType = {
setStandings(standingsViewModel); presenter: {
getViewModel(): { stats: LeagueDriverSeasonStatsDTO[] };
};
};
const typedUseCase =
getLeagueDriverSeasonStatsUseCase as GetLeagueDriverSeasonStatsUseCaseType;
const standingsViewModel = typedUseCase.presenter.getViewModel();
setStandings(standingsViewModel.stats);
const allDrivers = await driverRepo.findAll(); const allDrivers = await driverRepo.findAll();
const driverDtos: DriverDTO[] = allDrivers const driverDtos: DriverDTO[] = allDrivers
@@ -48,8 +55,19 @@ export default function LeagueStandingsPage() {
// Load league memberships from repository (consistent with other data) // Load league memberships from repository (consistent with other data)
const allMemberships = await membershipRepo.getLeagueMembers(leagueId); const allMemberships = await membershipRepo.getLeagueMembers(leagueId);
// Convert to the format expected by StandingsTable
const membershipData: LeagueMembership[] = allMemberships.map(m => ({ type RawMembership = {
id: string | number;
leagueId: string;
driverId: string;
role: MembershipRole;
status: LeagueMembership['status'];
joinedAt: string | Date;
};
// Convert to the format expected by StandingsTable (website-level LeagueMembership)
const membershipData: LeagueMembership[] = (allMemberships as RawMembership[]).map((m) => ({
id: String(m.id),
leagueId: m.leagueId, leagueId: m.leagueId,
driverId: m.driverId, driverId: m.driverId,
role: m.role, role: m.role,

View File

@@ -246,12 +246,15 @@ export default function ProtestReviewPage() {
}); });
const selectedPenalty = PENALTY_TYPES.find(p => p.type === penaltyType); const selectedPenalty = PENALTY_TYPES.find(p => p.type === penaltyType);
const penaltyValueToUse =
selectedPenalty && selectedPenalty.requiresValue ? penaltyValue : 0;
await penaltyUseCase.execute({ await penaltyUseCase.execute({
raceId: protest.raceId, raceId: protest.raceId,
driverId: protest.accusedDriverId, driverId: protest.accusedDriverId,
stewardId: currentDriverId, stewardId: currentDriverId,
type: penaltyType, type: penaltyType,
value: selectedPenalty?.requiresValue ? penaltyValue : undefined, value: penaltyValueToUse,
reason: protest.incident.description, reason: protest.incident.description,
protestId: protest.id, protestId: protest.id,
notes: stewardNotes, notes: stewardNotes,

View File

@@ -35,8 +35,8 @@ export default function CreateLeaguePage() {
const handleStepChange = (stepName: StepName) => { const handleStepChange = (stepName: StepName) => {
const params = new URLSearchParams( const params = new URLSearchParams(
searchParams && typeof (searchParams as any).toString === 'function' searchParams && typeof searchParams.toString === 'function'
? (searchParams as any).toString() ? searchParams.toString()
: '', : '',
); );
params.set('step', stepName); params.set('step', stepName);

View File

@@ -391,8 +391,11 @@ export default function LeaguesPage() {
try { try {
const useCase = getGetAllLeaguesWithCapacityAndScoringUseCase(); const useCase = getGetAllLeaguesWithCapacityAndScoringUseCase();
await useCase.execute(); await useCase.execute();
const viewModel = useCase.presenter.getViewModel(); const presenter = useCase.presenter as unknown as {
setRealLeagues(viewModel); getViewModel(): { leagues: LeagueSummaryDTO[] };
};
const viewModel = presenter.getViewModel();
setRealLeagues(viewModel.leagues);
} catch (error) { } catch (error) {
console.error('Failed to load leagues:', error); console.error('Failed to load leagues:', error);
} finally { } finally {

View File

@@ -284,15 +284,14 @@ export default function ProfilePage() {
// Use GetProfileOverviewUseCase to load all profile data // Use GetProfileOverviewUseCase to load all profile data
const profileUseCase = getGetProfileOverviewUseCase(); const profileUseCase = getGetProfileOverviewUseCase();
await profileUseCase.execute({ driverId: currentDriverId }); const profileViewModel = await profileUseCase.execute({ driverId: currentDriverId });
const profileViewModel = profileUseCase.presenter.getViewModel();
if (profileViewModel && profileViewModel.currentDriver) { if (profileViewModel && profileViewModel.currentDriver) {
// Set driver from ViewModel instead of direct repository access // Set driver from ViewModel instead of direct repository access
const driverData: DriverDTO = { const driverData: DriverDTO = {
id: profileViewModel.currentDriver.id, id: profileViewModel.currentDriver.id,
name: profileViewModel.currentDriver.name, name: profileViewModel.currentDriver.name,
iracingId: profileViewModel.currentDriver.iracingId, iracingId: profileViewModel.currentDriver.iracingId ?? '',
country: profileViewModel.currentDriver.country, country: profileViewModel.currentDriver.country,
bio: profileViewModel.currentDriver.bio || '', bio: profileViewModel.currentDriver.bio || '',
joinedAt: profileViewModel.currentDriver.joinedAt, joinedAt: profileViewModel.currentDriver.joinedAt,
@@ -335,11 +334,14 @@ export default function ProfilePage() {
try { try {
const updateProfileUseCase = getUpdateDriverProfileUseCase(); const updateProfileUseCase = getUpdateDriverProfileUseCase();
const updatedDto = await updateProfileUseCase.execute({ const input: { driverId: string; bio?: string; country?: string } = { driverId: driver.id };
driverId: driver.id, if (typeof updates.bio === 'string') {
bio: updates.bio, input.bio = updates.bio;
country: updates.country, }
}); if (typeof updates.country === 'string') {
input.country = updates.country;
}
const updatedDto = await updateProfileUseCase.execute(input);
if (updatedDto) { if (updatedDto) {
setDriver(updatedDto); setDriver(updatedDto);
@@ -468,7 +470,9 @@ export default function ProfilePage() {
<> <>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30"> <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
<Star className="w-4 h-4 text-primary-blue" /> <Star className="w-4 h-4 text-primary-blue" />
<span className="font-mono font-bold text-primary-blue">{stats.rating}</span> <span className="font-mono font-bold text-primary-blue">
{stats.rating ?? 0}
</span>
<span className="text-xs text-gray-400">Rating</span> <span className="text-xs text-gray-400">Rating</span>
</div> </div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30"> <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30">
@@ -644,7 +648,7 @@ export default function ProfilePage() {
</div> </div>
<div className="flex gap-6"> <div className="flex gap-6">
<CircularProgress <CircularProgress
value={stats.consistency} value={stats.consistency ?? 0}
max={100} max={100}
label="Consistency" label="Consistency"
color="text-primary-blue" color="text-primary-blue"
@@ -684,7 +688,9 @@ export default function ProfilePage() {
<Target className="w-4 h-4 text-primary-blue" /> <Target className="w-4 h-4 text-primary-blue" />
<span className="text-xs text-gray-500 uppercase">Avg Finish</span> <span className="text-xs text-gray-500 uppercase">Avg Finish</span>
</div> </div>
<p className="text-2xl font-bold text-primary-blue">P{stats.avgFinish.toFixed(1)}</p> <p className="text-2xl font-bold text-primary-blue">
P{(stats.avgFinish ?? 0).toFixed(1)}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -758,7 +764,9 @@ export default function ProfilePage() {
<div className="text-xs text-gray-500 uppercase tracking-wider">Podiums</div> <div className="text-xs text-gray-500 uppercase tracking-wider">Podiums</div>
</div> </div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center"> <div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.consistency}%</div> <div className="text-3xl font-bold text-primary-blue mb-1">
{stats.consistency ?? 0}%
</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Consistency</div> <div className="text-xs text-gray-500 uppercase tracking-wider">Consistency</div>
</div> </div>
</div> </div>
@@ -878,7 +886,11 @@ export default function ProfilePage() {
<p className="text-white font-semibold text-sm">{achievement.title}</p> <p className="text-white font-semibold text-sm">{achievement.title}</p>
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p> <p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
<p className="text-gray-500 text-xs mt-1"> <p className="text-gray-500 text-xs mt-1">
{new Date(achievement.earnedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} {new Date(achievement.earnedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p> </p>
</div> </div>
</div> </div>
@@ -887,6 +899,7 @@ export default function ProfilePage() {
})} })}
</div> </div>
</Card> </Card>
)}
{/* Friends Preview */} {/* Friends Preview */}
{socialSummary && socialSummary.friends.length > 0 && ( {socialSummary && socialSummary.friends.length > 0 && (
@@ -987,7 +1000,9 @@ export default function ProfilePage() {
<Activity className="w-4 h-4 text-primary-blue" /> <Activity className="w-4 h-4 text-primary-blue" />
<span className="text-xs text-gray-500 uppercase">Consistency</span> <span className="text-xs text-gray-500 uppercase">Consistency</span>
</div> </div>
<p className="text-2xl font-bold text-primary-blue">{stats.consistency}%</p> <p className="text-2xl font-bold text-primary-blue">
{stats.consistency ?? 0}%
</p>
</div> </div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline"> <div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
@@ -1015,7 +1030,9 @@ export default function ProfilePage() {
<div className="text-xs text-gray-400 uppercase">Best Finish</div> <div className="text-xs text-gray-400 uppercase">Best Finish</div>
</div> </div>
<div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center"> <div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
<div className="text-4xl font-bold text-primary-blue mb-1">P{stats.avgFinish.toFixed(1)}</div> <div className="text-4xl font-bold text-primary-blue mb-1">
P{(stats.avgFinish ?? 0).toFixed(1)}
</div>
<div className="text-xs text-gray-400 uppercase">Avg Finish</div> <div className="text-xs text-gray-400 uppercase">Avg Finish</div>
</div> </div>
<div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center"> <div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center">
@@ -1044,7 +1061,9 @@ export default function ProfilePage() {
</div> </div>
<div className="p-6 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center"> <div className="p-6 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
<Star className="w-8 h-8 text-primary-blue mx-auto mb-3" /> <Star className="w-8 h-8 text-primary-blue mx-auto mb-3" />
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.rating}</div> <div className="text-3xl font-bold text-primary-blue mb-1">
{stats.rating ?? 0}
</div>
<div className="text-sm text-gray-400">Rating</div> <div className="text-sm text-gray-400">Rating</div>
</div> </div>
<div className="p-6 rounded-xl bg-gradient-to-br from-purple-400/20 to-purple-600/5 border border-purple-400/30 text-center"> <div className="p-6 rounded-xl bg-gradient-to-br from-purple-400/20 to-purple-600/5 border border-purple-400/30 text-center">

View File

@@ -16,6 +16,7 @@ import {
getLeagueMembershipRepository, getLeagueMembershipRepository,
getTeamMembershipRepository, getTeamMembershipRepository,
} from '@/lib/di-container'; } from '@/lib/di-container';
import { PendingSponsorshipRequestsPresenter } from '@/lib/presenters/PendingSponsorshipRequestsPresenter';
import { useEffectiveDriverId } from '@/lib/currentDriver'; import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import { Handshake, User, Users, Trophy, ChevronRight, Building, AlertTriangle } from 'lucide-react'; import { Handshake, User, Users, Trophy, ChevronRight, Building, AlertTriangle } from 'lucide-react';
@@ -51,12 +52,17 @@ export default function SponsorshipRequestsPage() {
const allSections: EntitySection[] = []; const allSections: EntitySection[] = [];
// 1. Driver's own sponsorship requests // 1. Driver's own sponsorship requests
const driverResult = await query.execute({ const driverPresenter = new PendingSponsorshipRequestsPresenter();
await useCase.execute(
{
entityType: 'driver', entityType: 'driver',
entityId: currentDriverId, entityId: currentDriverId,
}); },
driverPresenter,
);
const driverResult = driverPresenter.getViewModel();
if (driverResult.requests.length > 0) { if (driverResult && driverResult.requests.length > 0) {
const driver = await driverRepo.findById(currentDriverId); const driver = await driverRepo.findById(currentDriverId);
allSections.push({ allSections.push({
entityType: 'driver', entityType: 'driver',
@@ -74,12 +80,17 @@ export default function SponsorshipRequestsPage() {
// Load sponsorship requests for this league's active season // Load sponsorship requests for this league's active season
try { try {
// For simplicity, we'll query by season entityType - in production you'd get the active season ID // For simplicity, we'll query by season entityType - in production you'd get the active season ID
const leagueResult = await query.execute({ const leaguePresenter = new PendingSponsorshipRequestsPresenter();
await useCase.execute(
{
entityType: 'season', entityType: 'season',
entityId: league.id, // Using league ID as a proxy for now entityId: league.id, // Using league ID as a proxy for now
}); },
leaguePresenter,
);
const leagueResult = leaguePresenter.getViewModel();
if (leagueResult.requests.length > 0) { if (leagueResult && leagueResult.requests.length > 0) {
allSections.push({ allSections.push({
entityType: 'season', entityType: 'season',
entityId: league.id, entityId: league.id,
@@ -98,12 +109,17 @@ export default function SponsorshipRequestsPage() {
for (const team of allTeams) { for (const team of allTeams) {
const membership = await teamMembershipRepo.getMembership(team.id, currentDriverId); const membership = await teamMembershipRepo.getMembership(team.id, currentDriverId);
if (membership && (membership.role === 'owner' || membership.role === 'manager')) { if (membership && (membership.role === 'owner' || membership.role === 'manager')) {
const teamResult = await query.execute({ const teamPresenter = new PendingSponsorshipRequestsPresenter();
await useCase.execute(
{
entityType: 'team', entityType: 'team',
entityId: team.id, entityId: team.id,
}); },
teamPresenter,
);
const teamResult = teamPresenter.getViewModel();
if (teamResult.requests.length > 0) { if (teamResult && teamResult.requests.length > 0) {
allSections.push({ allSections.push({
entityType: 'team', entityType: 'team',
entityId: team.id, entityId: team.id,
@@ -138,11 +154,14 @@ export default function SponsorshipRequestsPage() {
const handleReject = async (requestId: string, reason?: string) => { const handleReject = async (requestId: string, reason?: string) => {
const useCase = getRejectSponsorshipRequestUseCase(); const useCase = getRejectSponsorshipRequestUseCase();
await useCase.execute({ const input: { requestId: string; respondedBy: string; reason?: string } = {
requestId, requestId,
respondedBy: currentDriverId, respondedBy: currentDriverId,
reason, };
}); if (typeof reason === 'string') {
input.reason = reason;
}
await useCase.execute(input);
await loadAllRequests(); await loadAllRequests();
}; };

View File

@@ -928,7 +928,7 @@ export default function RaceDetailPage() {
isOpen={showProtestModal} isOpen={showProtestModal}
onClose={() => setShowProtestModal(false)} onClose={() => setShowProtestModal(false)}
raceId={race.id} raceId={race.id}
leagueId={league?.id} leagueId={league ? league.id : ''}
protestingDriverId={currentDriverId} protestingDriverId={currentDriverId}
participants={entryList.map(d => ({ id: d.id, name: d.name }))} participants={entryList.map(d => ({ id: d.id, name: d.name }))}
/> />

View File

@@ -115,13 +115,17 @@ export default function RaceResultsPage() {
setPointsSystem(viewModel.pointsSystem); setPointsSystem(viewModel.pointsSystem);
setFastestLapTime(viewModel.fastestLapTime); setFastestLapTime(viewModel.fastestLapTime);
setCurrentDriverId(viewModel.currentDriverId); setCurrentDriverId(viewModel.currentDriverId);
setPenalties( const mappedPenalties: PenaltyData[] = viewModel.penalties.map((p) => {
viewModel.penalties.map((p) => ({ const base: PenaltyData = {
driverId: p.driverId, driverId: p.driverId,
type: p.type as PenaltyTypeDTO, type: p.type as PenaltyTypeDTO,
value: p.value, };
})), if (typeof p.value === 'number') {
); return { ...base, value: p.value };
}
return base;
});
setPenalties(mappedPenalties);
} }
try { try {
@@ -287,9 +291,9 @@ export default function RaceResultsPage() {
results={results} results={results}
drivers={drivers} drivers={drivers}
pointsSystem={pointsSystem} pointsSystem={pointsSystem}
fastestLapTime={fastestLapTime} fastestLapTime={fastestLapTime ?? 0}
penalties={penalties} penalties={penalties}
currentDriverId={currentDriverId} currentDriverId={currentDriverId ?? ''}
/> />
) : ( ) : (
<> <>

View File

@@ -31,6 +31,8 @@ import {
} from '@/lib/di-container'; } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver'; import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import { RaceProtestsPresenter } from '@/lib/presenters/RaceProtestsPresenter';
import { RacePenaltiesPresenter } from '@/lib/presenters/RacePenaltiesPresenter';
import type { RaceProtestViewModel } from '@gridpilot/racing/application/presenters/IRaceProtestsPresenter'; import type { RaceProtestViewModel } from '@gridpilot/racing/application/presenters/IRaceProtestsPresenter';
import type { RacePenaltyViewModel } from '@gridpilot/racing/application/presenters/IRacePenaltiesPresenter'; import type { RacePenaltyViewModel } from '@gridpilot/racing/application/presenters/IRacePenaltiesPresenter';
import type { League } from '@gridpilot/racing/domain/entities/League'; import type { League } from '@gridpilot/racing/domain/entities/League';
@@ -42,6 +44,8 @@ export default function RaceStewardingPage() {
const raceId = params.id as string; const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
const driversById: Record<string, { name?: string }> = {};
const [race, setRace] = useState<Race | null>(null); const [race, setRace] = useState<Race | null>(null);
const [league, setLeague] = useState<League | null>(null); const [league, setLeague] = useState<League | null>(null);
const [protests, setProtests] = useState<RaceProtestViewModel[]>([]); const [protests, setProtests] = useState<RaceProtestViewModel[]>([]);
@@ -78,13 +82,15 @@ export default function RaceStewardingPage() {
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
} }
await protestsUseCase.execute(raceId); const protestsPresenter = new RaceProtestsPresenter();
const protestsViewModel = protestsUseCase.presenter.getViewModel(); await protestsUseCase.execute({ raceId }, protestsPresenter);
setProtests(protestsViewModel.protests); const protestsViewModel = protestsPresenter.getViewModel();
setProtests(protestsViewModel?.protests ?? []);
await penaltiesUseCase.execute(raceId); const penaltiesPresenter = new RacePenaltiesPresenter();
const penaltiesViewModel = penaltiesUseCase.presenter.getViewModel(); await penaltiesUseCase.execute({ raceId }, penaltiesPresenter);
setPenalties(penaltiesViewModel.penalties); const penaltiesViewModel = penaltiesPresenter.getViewModel();
setPenalties(penaltiesViewModel?.penalties ?? []);
} catch (err) { } catch (err) {
console.error('Failed to load data:', err); console.error('Failed to load data:', err);
} finally { } finally {

View File

@@ -105,8 +105,9 @@ export default function AllRacesPage() {
setCurrentPage(1); setCurrentPage(1);
}, [statusFilter, leagueFilter, searchQuery]); }, [statusFilter, leagueFilter, searchQuery]);
const formatDate = (date: Date) => { const formatDate = (date: Date | string) => {
return new Date(date).toLocaleDateString('en-US', { const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('en-US', {
weekday: 'short', weekday: 'short',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@@ -114,8 +115,9 @@ export default function AllRacesPage() {
}); });
}; };
const formatTime = (date: Date) => { const formatTime = (date: Date | string) => {
return new Date(date).toLocaleTimeString('en-US', { const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleTimeString('en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}); });

View File

@@ -93,8 +93,11 @@ export default function RacesPage() {
// Group races by date for calendar view // Group races by date for calendar view
const racesByDate = useMemo(() => { const racesByDate = useMemo(() => {
const grouped = new Map<string, RaceListItemViewModel[]>(); const grouped = new Map<string, RaceListItemViewModel[]>();
filteredRaces.forEach(race => { filteredRaces.forEach((race) => {
const dateKey = new Date(race.scheduledAt).toISOString().split('T')[0]; if (typeof race.scheduledAt !== 'string') {
return;
}
const dateKey = race.scheduledAt.split('T')[0]!;
if (!grouped.has(dateKey)) { if (!grouped.has(dateKey)) {
grouped.set(dateKey, []); grouped.set(dateKey, []);
} }
@@ -108,23 +111,26 @@ export default function RacesPage() {
const recentResults = pageData?.recentResults ?? []; const recentResults = pageData?.recentResults ?? [];
const stats = pageData?.stats ?? { total: 0, scheduled: 0, running: 0, completed: 0 }; const stats = pageData?.stats ?? { total: 0, scheduled: 0, running: 0, completed: 0 };
const formatDate = (date: Date) => { const formatDate = (date: Date | string) => {
return new Date(date).toLocaleDateString('en-US', { const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('en-US', {
weekday: 'short', weekday: 'short',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
}); });
}; };
const formatTime = (date: Date) => { const formatTime = (date: Date | string) => {
return new Date(date).toLocaleTimeString('en-US', { const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleTimeString('en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}); });
}; };
const formatFullDate = (date: Date) => { const formatFullDate = (date: Date | string) => {
return new Date(date).toLocaleDateString('en-US', { const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
@@ -132,9 +138,10 @@ export default function RacesPage() {
}); });
}; };
const getRelativeTime = (date: Date) => { const getRelativeTime = (date?: Date | string) => {
if (!date) return '';
const now = new Date(); const now = new Date();
const targetDate = new Date(date); const targetDate = typeof date === 'string' ? new Date(date) : date;
const diffMs = targetDate.getTime() - now.getTime(); const diffMs = targetDate.getTime() - now.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
@@ -144,7 +151,7 @@ export default function RacesPage() {
if (diffHours < 24) return `In ${diffHours}h`; if (diffHours < 24) return `In ${diffHours}h`;
if (diffDays === 1) return 'Tomorrow'; if (diffDays === 1) return 'Tomorrow';
if (diffDays < 7) return `In ${diffDays} days`; if (diffDays < 7) return `In ${diffDays} days`;
return formatDate(date); return formatDate(targetDate);
}; };
const statusConfig = { const statusConfig = {
@@ -368,6 +375,9 @@ export default function RacesPage() {
{/* Races for this date */} {/* Races for this date */}
<div className="space-y-2"> <div className="space-y-2">
{dayRaces.map((race) => { {dayRaces.map((race) => {
if (!race.scheduledAt) {
return null;
}
const config = statusConfig[race.status]; const config = statusConfig[race.status];
const StatusIcon = config.icon; const StatusIcon = config.icon;
@@ -385,9 +395,13 @@ export default function RacesPage() {
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{/* Time Column */} {/* Time Column */}
<div className="flex-shrink-0 text-center min-w-[60px]"> <div className="flex-shrink-0 text-center min-w-[60px]">
<p className="text-lg font-bold text-white">{formatTime(new Date(race.scheduledAt))}</p> <p className="text-lg font-bold text-white">
{formatTime(race.scheduledAt)}
</p>
<p className={`text-xs ${config.color}`}> <p className={`text-xs ${config.color}`}>
{race.status === 'running' ? 'LIVE' : getRelativeTime(new Date(race.scheduledAt))} {race.status === 'running'
? 'LIVE'
: getRelativeTime(race.scheduledAt)}
</p> </p>
</div> </div>
@@ -427,7 +441,7 @@ export default function RacesPage() {
{/* League Link */} {/* League Link */}
<div className="mt-3 pt-3 border-t border-charcoal-outline/50"> <div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<Link <Link
href={`/leagues/${race.leagueId}`} href={`/leagues/${race.leagueId ?? ''}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline" className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
> >
@@ -482,7 +496,12 @@ export default function RacesPage() {
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{upcomingRaces.map((race) => ( {upcomingRaces.map((race) => {
if (!race.scheduledAt) {
return null;
}
const scheduledAtDate = new Date(race.scheduledAt);
return (
<div <div
key={race.id} key={race.id}
onClick={() => router.push(`/races/${race.id}`)} onClick={() => router.push(`/races/${race.id}`)}
@@ -490,16 +509,17 @@ export default function RacesPage() {
> >
<div className="flex-shrink-0 w-10 h-10 bg-primary-blue/10 rounded-lg flex items-center justify-center"> <div className="flex-shrink-0 w-10 h-10 bg-primary-blue/10 rounded-lg flex items-center justify-center">
<span className="text-sm font-bold text-primary-blue"> <span className="text-sm font-bold text-primary-blue">
{new Date(race.scheduledAt).getDate()} {scheduledAtDate.getDate()}
</span> </span>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">{race.track}</p> <p className="text-sm font-medium text-white truncate">{race.track}</p>
<p className="text-xs text-gray-500">{formatTime(new Date(race.scheduledAt))}</p> <p className="text-xs text-gray-500">{formatTime(scheduledAtDate)}</p>
</div> </div>
<ChevronRight className="w-4 h-4 text-gray-500" /> <ChevronRight className="w-4 h-4 text-gray-500" />
</div> </div>
))} );
})}
</div> </div>
)} )}
</Card> </Card>

View File

@@ -9,6 +9,7 @@ import Button from '@/components/ui/Button';
import Breadcrumbs from '@/components/layout/Breadcrumbs'; import Breadcrumbs from '@/components/layout/Breadcrumbs';
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard'; import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
import { getImageService } from '@/lib/di-container'; import { getImageService } from '@/lib/di-container';
import { TeamMembersPresenter } from '@/lib/presenters/TeamMembersPresenter';
import TeamRoster from '@/components/teams/TeamRoster'; import TeamRoster from '@/components/teams/TeamRoster';
import TeamStandings from '@/components/teams/TeamStandings'; import TeamStandings from '@/components/teams/TeamStandings';
import TeamAdmin from '@/components/teams/TeamAdmin'; import TeamAdmin from '@/components/teams/TeamAdmin';
@@ -19,9 +20,17 @@ import {
getTeamMembershipRepository, getTeamMembershipRepository,
} from '@/lib/di-container'; } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver'; import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { Team, TeamMembership, TeamRole } from '@gridpilot/racing'; import type { Team } from '@gridpilot/racing';
import { Users, Trophy, TrendingUp, Star, Zap } from 'lucide-react'; import { Users, Trophy, TrendingUp, Star, Zap } from 'lucide-react';
type TeamRole = 'owner' | 'manager' | 'driver';
interface TeamMembership {
driverId: string;
role: TeamRole;
joinedAt: Date;
}
type Tab = 'overview' | 'roster' | 'standings' | 'admin'; type Tab = 'overview' | 'roster' | 'standings' | 'admin';
export default function TeamDetailPage() { export default function TeamDetailPage() {
@@ -42,16 +51,32 @@ export default function TeamDetailPage() {
const detailsUseCase = getGetTeamDetailsUseCase(); const detailsUseCase = getGetTeamDetailsUseCase();
const membersUseCase = getGetTeamMembersUseCase(); const membersUseCase = getGetTeamMembersUseCase();
await detailsUseCase.execute({ teamId, driverId: currentDriverId }); await detailsUseCase.execute(teamId, currentDriverId);
const detailsViewModel = detailsUseCase.presenter.getViewModel(); const detailsPresenter = detailsUseCase.presenter;
const detailsViewModel = detailsPresenter
? (detailsPresenter as any).getViewModel?.() as { team: Team } | null
: null;
await membersUseCase.execute({ teamId }); if (!detailsViewModel) {
const membersViewModel = membersUseCase.presenter.getViewModel(); setTeam(null);
const teamMemberships = membersViewModel.members; setMemberships([]);
setIsAdmin(false);
return;
}
const teamMembersPresenter = new TeamMembersPresenter();
await membersUseCase.execute({ teamId }, teamMembersPresenter);
const membersViewModel = teamMembersPresenter.getViewModel();
const teamMemberships: TeamMembership[] = (membersViewModel?.members ?? []).map((m) => ({
driverId: m.driverId,
role: m.role as TeamRole,
joinedAt: new Date(m.joinedAt),
}));
const adminStatus = const adminStatus =
teamMemberships.some( teamMemberships.some(
(m) => (m: TeamMembership) =>
m.driverId === currentDriverId && m.driverId === currentDriverId &&
(m.role === 'owner' || m.role === 'manager'), (m.role === 'owner' || m.role === 'manager'),
) ?? false; ) ?? false;

View File

@@ -23,6 +23,7 @@ import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading'; import Heading from '@/components/ui/Heading';
import { getGetTeamsLeaderboardUseCase } from '@/lib/di-container'; import { getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
import { TeamsLeaderboardPresenter } from '@/lib/presenters/TeamsLeaderboardPresenter';
import type { import type {
TeamLeaderboardItemViewModel, TeamLeaderboardItemViewModel,
SkillLevel, SkillLevel,
@@ -36,6 +37,23 @@ type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
type TeamDisplayData = TeamLeaderboardItemViewModel; type TeamDisplayData = TeamLeaderboardItemViewModel;
const getSafeRating = (team: TeamDisplayData): number => {
const value = typeof team.rating === 'number' ? team.rating : 0;
return Number.isFinite(value) ? value : 0;
};
const getSafeTotalWins = (team: TeamDisplayData): number => {
const raw = team.totalWins;
const value = typeof raw === 'number' ? raw : 0;
return Number.isFinite(value) ? value : 0;
};
const getSafeTotalRaces = (team: TeamDisplayData): number => {
const raw = team.totalRaces;
const value = typeof raw === 'number' ? raw : 0;
return Number.isFinite(value) ? value : 0;
};
// ============================================================================ // ============================================================================
// SKILL LEVEL CONFIG // SKILL LEVEL CONFIG
// ============================================================================ // ============================================================================
@@ -103,11 +121,15 @@ interface TopThreePodiumProps {
} }
function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) { function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
const top3 = teams.slice(0, 3); const top3 = teams.slice(0, 3) as [TeamDisplayData, TeamDisplayData, TeamDisplayData];
if (top3.length < 3) return null; if (teams.length < 3) return null;
// Display order: 2nd, 1st, 3rd // Display order: 2nd, 1st, 3rd
const podiumOrder = [top3[1], top3[0], top3[2]]; const podiumOrder: [TeamDisplayData, TeamDisplayData, TeamDisplayData] = [
top3[1],
top3[0],
top3[2],
];
const podiumHeights = ['h-28', 'h-36', 'h-20']; const podiumHeights = ['h-28', 'h-36', 'h-20'];
const podiumPositions = [2, 1, 3]; const podiumPositions = [2, 1, 3];
@@ -159,7 +181,7 @@ function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
<div className="flex items-end justify-center gap-4 md:gap-8"> <div className="flex items-end justify-center gap-4 md:gap-8">
{podiumOrder.map((team, index) => { {podiumOrder.map((team, index) => {
const position = podiumPositions[index]; const position = podiumPositions[index] ?? 0;
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel); const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
const LevelIcon = levelConfig?.icon || Shield; const LevelIcon = levelConfig?.icon || Shield;
@@ -172,7 +194,7 @@ function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
> >
{/* Team card */} {/* Team card */}
<div <div
className={`relative mb-4 p-4 rounded-xl bg-gradient-to-br ${getGradient(position)} border ${getBorderColor(position)} transition-all group-hover:scale-105 group-hover:shadow-lg`} className={`relative mb-4 p-4 rounded-xl bg-gradient-to-br ${getGradient(position ?? 0)} border ${getBorderColor(position ?? 0)} transition-all group-hover:scale-105 group-hover:shadow-lg`}
> >
{/* Crown for 1st place */} {/* Crown for 1st place */}
{position === 1 && ( {position === 1 && (
@@ -198,14 +220,14 @@ function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
{/* Rating */} {/* Rating */}
<p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}> <p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}>
{team.rating?.toLocaleString() ?? '—'} {getSafeRating(team).toLocaleString()}
</p> </p>
{/* Stats row */} {/* Stats row */}
<div className="flex items-center justify-center gap-3 mt-2 text-xs text-gray-400"> <div className="flex items-center justify-center gap-3 mt-2 text-xs text-gray-400">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Trophy className="w-3 h-3 text-performance-green" /> <Trophy className="w-3 h-3 text-performance-green" />
{team.totalWins} {getSafeTotalWins(team)}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Users className="w-3 h-3 text-purple-400" /> <Users className="w-3 h-3 text-purple-400" />
@@ -246,9 +268,14 @@ export default function TeamLeaderboardPage() {
const loadTeams = async () => { const loadTeams = async () => {
try { try {
const useCase = getGetTeamsLeaderboardUseCase(); const useCase = getGetTeamsLeaderboardUseCase();
await useCase.execute(); const presenter = new TeamsLeaderboardPresenter();
const viewModel = useCase.presenter.getViewModel();
await useCase.execute(undefined as void, presenter);
const viewModel = presenter.getViewModel();
if (viewModel) {
setTeams(viewModel.teams); setTeams(viewModel.teams);
}
} catch (error) { } catch (error) {
console.error('Failed to load teams:', error); console.error('Failed to load teams:', error);
} finally { } finally {
@@ -286,17 +313,30 @@ export default function TeamLeaderboardPage() {
}) })
.sort((a, b) => { .sort((a, b) => {
switch (sortBy) { switch (sortBy) {
case 'rating': case 'rating': {
return (b.rating ?? 0) - (a.rating ?? 0); const aRating = getSafeRating(a);
case 'wins': const bRating = getSafeRating(b);
return b.totalWins - a.totalWins; return bRating - aRating;
}
case 'wins': {
const aWinsSort = getSafeTotalWins(a);
const bWinsSort = getSafeTotalWins(b);
return bWinsSort - aWinsSort;
}
case 'winRate': { case 'winRate': {
const aRate = a.totalRaces > 0 ? a.totalWins / a.totalRaces : 0; const aRaces = getSafeTotalRaces(a);
const bRate = b.totalRaces > 0 ? b.totalWins / b.totalRaces : 0; const bRaces = getSafeTotalRaces(b);
const aWins = getSafeTotalWins(a);
const bWins = getSafeTotalWins(b);
const aRate = aRaces > 0 ? aWins / aRaces : 0;
const bRate = bRaces > 0 ? bWins / bRaces : 0;
return bRate - aRate; return bRate - aRate;
} }
case 'races': case 'races': {
return b.totalRaces - a.totalRaces; const aRacesSort = getSafeTotalRaces(a);
const bRacesSort = getSafeTotalRaces(b);
return bRacesSort - aRacesSort;
}
default: default:
return 0; return 0;
} }
@@ -468,7 +508,10 @@ export default function TeamLeaderboardPage() {
<span className="text-xs text-gray-500">Total Wins</span> <span className="text-xs text-gray-500">Total Wins</span>
</div> </div>
<p className="text-2xl font-bold text-white"> <p className="text-2xl font-bold text-white">
{filteredAndSortedTeams.reduce((sum, t) => sum + t.totalWins, 0)} {filteredAndSortedTeams.reduce<number>(
(sum, t) => sum + getSafeTotalWins(t),
0,
)}
</p> </p>
</div> </div>
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline"> <div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
@@ -477,7 +520,10 @@ export default function TeamLeaderboardPage() {
<span className="text-xs text-gray-500">Total Races</span> <span className="text-xs text-gray-500">Total Races</span>
</div> </div>
<p className="text-2xl font-bold text-white"> <p className="text-2xl font-bold text-white">
{filteredAndSortedTeams.reduce((sum, t) => sum + t.totalRaces, 0)} {filteredAndSortedTeams.reduce<number>(
(sum, t) => sum + getSafeTotalRaces(t),
0,
)}
</p> </p>
</div> </div>
</div> </div>
@@ -499,7 +545,10 @@ export default function TeamLeaderboardPage() {
{filteredAndSortedTeams.map((team, index) => { {filteredAndSortedTeams.map((team, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel); const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
const LevelIcon = levelConfig?.icon || Shield; const LevelIcon = levelConfig?.icon || Shield;
const winRate = team.totalRaces > 0 ? ((team.totalWins / team.totalRaces) * 100).toFixed(1) : '0.0'; const totalRaces = getSafeTotalRaces(team);
const totalWins = getSafeTotalWins(team);
const winRate =
totalRaces > 0 ? ((totalWins / totalRaces) * 100).toFixed(1) : '0.0';
return ( return (
<button <button
@@ -565,15 +614,19 @@ export default function TeamLeaderboardPage() {
{/* Rating */} {/* Rating */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center"> <div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'rating' ? 'text-purple-400' : 'text-white'}`}> <span
{team.rating?.toLocaleString() ?? '—'} className={`font-mono font-semibold ${
sortBy === 'rating' ? 'text-purple-400' : 'text-white'
}`}
>
{getSafeRating(team).toLocaleString()}
</span> </span>
</div> </div>
{/* Wins */} {/* Wins */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center"> <div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'wins' ? 'text-purple-400' : 'text-white'}`}> <span className={`font-mono font-semibold ${sortBy === 'wins' ? 'text-purple-400' : 'text-white'}`}>
{team.totalWins} {getSafeTotalWins(team)}
</span> </span>
</div> </div>

View File

@@ -29,6 +29,7 @@ import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading'; import Heading from '@/components/ui/Heading';
import CreateTeamForm from '@/components/teams/CreateTeamForm'; import CreateTeamForm from '@/components/teams/CreateTeamForm';
import { getGetTeamsLeaderboardUseCase } from '@/lib/di-container'; import { getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
import { TeamsLeaderboardPresenter } from '@/lib/presenters/TeamsLeaderboardPresenter';
import type { TeamLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter'; import type { TeamLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
// ============================================================================ // ============================================================================
@@ -204,7 +205,7 @@ function SkillLevelSection({ level, teams, onTeamClick, defaultExpanded = false
key={team.id} key={team.id}
id={team.id} id={team.id}
name={team.name} name={team.name}
description={team.description} description={team.description ?? ''}
memberCount={team.memberCount} memberCount={team.memberCount}
rating={team.rating} rating={team.rating}
totalWins={team.totalWins} totalWins={team.totalWins}
@@ -212,7 +213,7 @@ function SkillLevelSection({ level, teams, onTeamClick, defaultExpanded = false
performanceLevel={team.performanceLevel} performanceLevel={team.performanceLevel}
isRecruiting={team.isRecruiting} isRecruiting={team.isRecruiting}
specialization={team.specialization} specialization={team.specialization}
region={team.region} region={team.region ?? ''}
languages={team.languages} languages={team.languages}
onClick={() => onTeamClick(team.id)} onClick={() => onTeamClick(team.id)}
/> />
@@ -449,11 +450,16 @@ export default function TeamsPage() {
const loadTeams = async () => { const loadTeams = async () => {
try { try {
const useCase = getGetTeamsLeaderboardUseCase(); const useCase = getGetTeamsLeaderboardUseCase();
await useCase.execute(); const presenter = new TeamsLeaderboardPresenter();
const viewModel = useCase.presenter.getViewModel();
await useCase.execute(undefined as void, presenter);
const viewModel = presenter.getViewModel();
if (viewModel) {
setRealTeams(viewModel.teams); setRealTeams(viewModel.teams);
setGroupsBySkillLevel(viewModel.groupsBySkillLevel); setGroupsBySkillLevel(viewModel.groupsBySkillLevel);
setTopTeams(viewModel.topTeams); setTopTeams(viewModel.topTeams);
}
} catch (error) { } catch (error) {
console.error('Failed to load teams:', error); console.error('Failed to load teams:', error);
} finally { } finally {

View File

@@ -179,13 +179,18 @@ export default function DevToolbar() {
leagueRepository.findAll(), leagueRepository.findAll(),
]); ]);
const completedRaces = allRaces.filter((race: any) => race.status === 'completed'); const completedRaces = allRaces.filter((race) => race.status === 'completed');
const scheduledRaces = allRaces.filter((race: any) => race.status === 'scheduled'); const scheduledRaces = allRaces.filter((race) => race.status === 'scheduled');
const primaryRace = completedRaces[0] ?? allRaces[0]; const primaryRace = completedRaces[0] ?? allRaces[0];
const secondaryRace = scheduledRaces[0] ?? allRaces[1] ?? primaryRace; const secondaryRace = scheduledRaces[0] ?? allRaces[1] ?? primaryRace;
const primaryLeague = allLeagues[0]; const primaryLeague = allLeagues[0];
const notificationDeadline =
selectedUrgency === 'modal'
? new Date(Date.now() + 48 * 60 * 60 * 1000)
: undefined;
let title: string; let title: string;
let body: string; let body: string;
let notificationType: 'protest_filed' | 'protest_defense_requested' | 'protest_vote_required'; let notificationType: 'protest_filed' | 'protest_defense_requested' | 'protest_vote_required';
@@ -227,7 +232,7 @@ export default function DevToolbar() {
{ label: 'View Protest', type: 'primary' as const, href: actionUrl, actionId: 'view' }, { label: 'View Protest', type: 'primary' as const, href: actionUrl, actionId: 'view' },
{ label: 'Dismiss', type: 'secondary' as const, actionId: 'dismiss' }, { label: 'Dismiss', type: 'secondary' as const, actionId: 'dismiss' },
] ]
: undefined; : [];
await sendNotification.execute({ await sendNotification.execute({
recipientId: currentDriverId, recipientId: currentDriverId,
@@ -240,12 +245,9 @@ export default function DevToolbar() {
actions, actions,
data: { data: {
protestId: `demo-protest-${Date.now()}`, protestId: `demo-protest-${Date.now()}`,
raceId: primaryRace?.id, raceId: primaryRace?.id ?? '',
leagueId: primaryLeague?.id, leagueId: primaryLeague?.id ?? '',
deadline: ...(notificationDeadline ? { deadline: notificationDeadline } : {}),
selectedUrgency === 'modal'
? new Date(Date.now() + 48 * 60 * 60 * 1000)
: undefined,
}, },
}); });

View File

@@ -70,13 +70,14 @@ export default function CreateDriverForm() {
try { try {
const driverRepo = getDriverRepository(); const driverRepo = getDriverRepository();
const bio = formData.bio.trim();
const driver = Driver.create({ const driver = Driver.create({
id: crypto.randomUUID(), id: crypto.randomUUID(),
iracingId: formData.iracingId.trim(), iracingId: formData.iracingId.trim(),
name: formData.name.trim(), name: formData.name.trim(),
country: formData.country.trim().toUpperCase(), country: formData.country.trim().toUpperCase(),
bio: formData.bio.trim() || undefined, ...(bio ? { bio } : {}),
}); });
await driverRepo.create(driver); await driverRepo.create(driver);

View File

@@ -40,7 +40,7 @@ export default function DriverCard(props: DriverCardProps) {
return ( return (
<Card <Card
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer" className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
onClick={onClick} {...(onClick ? { onClick } : {})}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1"> <div className="flex items-center gap-4 flex-1">

View File

@@ -9,8 +9,10 @@ import DriverRankings from './DriverRankings';
import PerformanceMetrics from './PerformanceMetrics'; import PerformanceMetrics from './PerformanceMetrics';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getLeagueRankings, getGetDriverTeamUseCase, getGetProfileOverviewUseCase } from '@/lib/di-container'; import { getLeagueRankings, getGetDriverTeamUseCase, getGetProfileOverviewUseCase } from '@/lib/di-container';
import { DriverTeamPresenter } from '@/lib/presenters/DriverTeamPresenter';
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership'; import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO'; import type { ProfileOverviewViewModel } from '@gridpilot/racing/application/presenters/IProfileOverviewPresenter';
import type { DriverTeamViewModel } from '@gridpilot/racing/application/presenters/IDriverTeamPresenter';
interface DriverProfileProps { interface DriverProfileProps {
driver: DriverDTO; driver: DriverDTO;
@@ -18,23 +20,39 @@ interface DriverProfileProps {
onEditClick?: () => void; onEditClick?: () => void;
} }
interface DriverProfileStatsViewModel {
rating: number;
wins: number;
podiums: number;
dnfs: number;
totalRaces: number;
avgFinish: number;
bestFinish: number;
worstFinish: number;
consistency: number;
percentile: number;
overallRank?: number;
}
type DriverProfileOverviewViewModel = ProfileOverviewViewModel | null;
export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) { export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
const [profileData, setProfileData] = useState<any>(null); const [profileData, setProfileData] = useState<DriverProfileOverviewViewModel>(null);
const [teamData, setTeamData] = useState<GetDriverTeamQueryResultDTO | null>(null); const [teamData, setTeamData] = useState<DriverTeamViewModel | null>(null);
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
// Load profile data using GetProfileOverviewUseCase // Load profile data using GetProfileOverviewUseCase
const profileUseCase = getGetProfileOverviewUseCase(); const profileUseCase = getGetProfileOverviewUseCase();
await profileUseCase.execute({ driverId: driver.id }); const profileViewModel = await profileUseCase.execute({ driverId: driver.id });
const profileViewModel = profileUseCase.presenter.getViewModel();
setProfileData(profileViewModel); setProfileData(profileViewModel);
// Load team data // Load team data using caller-owned presenter
const teamUseCase = getGetDriverTeamUseCase(); const teamUseCase = getGetDriverTeamUseCase();
await teamUseCase.execute({ driverId: driver.id }); const driverTeamPresenter = new DriverTeamPresenter();
const teamViewModel = teamUseCase.presenter.getViewModel(); await teamUseCase.execute({ driverId: driver.id }, driverTeamPresenter);
setTeamData(teamViewModel.result); const teamResult = driverTeamPresenter.getViewModel();
setTeamData(teamResult ?? null);
}; };
void load(); void load();
}, [driver.id]); }, [driver.id]);
@@ -44,27 +62,27 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
const leagueRank = primaryLeagueId const leagueRank = primaryLeagueId
? getLeagueRankings(driver.id, primaryLeagueId) ? getLeagueRankings(driver.id, primaryLeagueId)
: { rank: 0, totalDrivers: 0, percentile: 0 }; : { rank: 0, totalDrivers: 0, percentile: 0 };
const globalRank = profileData?.currentDriver?.globalRank || null; const globalRank = profileData?.currentDriver?.globalRank ?? null;
const totalDrivers = profileData?.currentDriver?.totalDrivers || 0; const totalDrivers = profileData?.currentDriver?.totalDrivers ?? 0;
const performanceStats = driverStats ? { const performanceStats = driverStats ? {
winRate: (driverStats.wins / driverStats.totalRaces) * 100, winRate: driverStats.totalRaces > 0 ? (driverStats.wins / driverStats.totalRaces) * 100 : 0,
podiumRate: (driverStats.podiums / driverStats.totalRaces) * 100, podiumRate: driverStats.totalRaces > 0 ? (driverStats.podiums / driverStats.totalRaces) * 100 : 0,
dnfRate: (driverStats.dnfs / driverStats.totalRaces) * 100, dnfRate: driverStats.totalRaces > 0 ? (driverStats.dnfs / driverStats.totalRaces) * 100 : 0,
avgFinish: driverStats.avgFinish, avgFinish: driverStats.avgFinish ?? 0,
consistency: driverStats.consistency, consistency: driverStats.consistency ?? 0,
bestFinish: driverStats.bestFinish, bestFinish: driverStats.bestFinish ?? 0,
worstFinish: driverStats.worstFinish, worstFinish: driverStats.worstFinish ?? 0,
} : null; } : null;
const rankings = driverStats ? [ const rankings = driverStats ? [
{ {
type: 'overall' as const, type: 'overall' as const,
name: 'Overall Ranking', name: 'Overall Ranking',
rank: globalRank || driverStats.overallRank || 0, rank: globalRank ?? driverStats.overallRank ?? 0,
totalDrivers: totalDrivers, totalDrivers,
percentile: driverStats.percentile, percentile: driverStats.percentile ?? 0,
rating: driverStats.rating, rating: driverStats.rating ?? 0,
}, },
{ {
type: 'league' as const, type: 'league' as const,
@@ -72,7 +90,7 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
rank: leagueRank.rank, rank: leagueRank.rank,
totalDrivers: leagueRank.totalDrivers, totalDrivers: leagueRank.totalDrivers,
percentile: leagueRank.percentile, percentile: leagueRank.percentile,
rating: driverStats.rating, rating: driverStats.rating ?? 0,
}, },
] : []; ] : [];
@@ -84,7 +102,7 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
rating={driverStats?.rating ?? null} rating={driverStats?.rating ?? null}
rank={driverStats?.overallRank ?? null} rank={driverStats?.overallRank ?? null}
isOwnProfile={isOwnProfile} isOwnProfile={isOwnProfile}
onEditClick={isOwnProfile ? onEditClick : undefined} onEditClick={onEditClick ?? (() => {})}
teamName={teamData?.team.name ?? null} teamName={teamData?.team.name ?? null}
teamTag={teamData?.team.tag ?? null} teamTag={teamData?.team.tag ?? null}
/> />
@@ -103,7 +121,11 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
<Card> <Card>
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3> <h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<StatCard label="Rating" value={driverStats.rating.toString()} color="text-primary-blue" /> <StatCard
label="Rating"
value={(driverStats.rating ?? 0).toString()}
color="text-primary-blue"
/>
<StatCard label="Total Races" value={driverStats.totalRaces.toString()} color="text-white" /> <StatCard label="Total Races" value={driverStats.totalRaces.toString()} color="text-white" />
<StatCard label="Wins" value={driverStats.wins.toString()} color="text-green-400" /> <StatCard label="Wins" value={driverStats.wins.toString()} color="text-green-400" />
<StatCard label="Podiums" value={driverStats.podiums.toString()} color="text-warning-amber" /> <StatCard label="Podiums" value={driverStats.podiums.toString()} color="text-warning-amber" />
@@ -130,14 +152,21 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
<Card> <Card>
<h3 className="text-lg font-semibold text-white mb-4">Performance by Class</h3> <h3 className="text-lg font-semibold text-white mb-4">Performance by Class</h3>
<ProfileStats stats={driverStats ? { {driverStats && (
<ProfileStats
stats={{
totalRaces: driverStats.totalRaces, totalRaces: driverStats.totalRaces,
wins: driverStats.wins, wins: driverStats.wins,
podiums: driverStats.podiums, podiums: driverStats.podiums,
dnfs: driverStats.dnfs, dnfs: driverStats.dnfs,
avgFinish: driverStats.avgFinish, avgFinish: driverStats.avgFinish ?? 0,
completionRate: ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100 completionRate:
} : undefined} /> driverStats.totalRaces > 0
? ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
: 0,
}}
/>
)}
</Card> </Card>
<CareerHighlights /> <CareerHighlights />

View File

@@ -135,7 +135,7 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
<Card> <Card>
<div className="space-y-2"> <div className="space-y-2">
{paginatedResults.map(({ race, result, league }) => { {paginatedResults.map(({ race, result, league }) => {
if (!result) return null; if (!result || !league) return null;
return ( return (
<RaceResultCard <RaceResultCard

View File

@@ -5,6 +5,7 @@ import RankBadge from './RankBadge';
import { getLeagueRankings, getGetProfileOverviewUseCase } from '@/lib/di-container'; import { getLeagueRankings, getGetProfileOverviewUseCase } from '@/lib/di-container';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership'; import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
import type { ProfileOverviewViewModel } from '@gridpilot/racing/application/presenters/IProfileOverviewPresenter';
interface ProfileStatsProps { interface ProfileStatsProps {
driverId?: string; driverId?: string;
@@ -18,15 +19,16 @@ interface ProfileStatsProps {
}; };
} }
type DriverProfileOverviewViewModel = ProfileOverviewViewModel | null;
export default function ProfileStats({ driverId, stats }: ProfileStatsProps) { export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
const [profileData, setProfileData] = useState<any>(null); const [profileData, setProfileData] = useState<DriverProfileOverviewViewModel>(null);
useEffect(() => { useEffect(() => {
if (driverId) { if (driverId) {
const load = async () => { const load = async () => {
const profileUseCase = getGetProfileOverviewUseCase(); const profileUseCase = getGetProfileOverviewUseCase();
await profileUseCase.execute({ driverId }); const vm = await profileUseCase.execute({ driverId });
const vm = profileUseCase.presenter.getViewModel();
setProfileData(vm); setProfileData(vm);
}; };
void load(); void load();
@@ -34,21 +36,24 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
}, [driverId]); }, [driverId]);
const driverStats = profileData?.stats || null; const driverStats = profileData?.stats || null;
const totalDrivers = profileData?.currentDriver?.totalDrivers || 0; const totalDrivers = profileData?.currentDriver?.totalDrivers ?? 0;
const primaryLeagueId = driverId ? getPrimaryLeagueIdForDriver(driverId) : null; const primaryLeagueId = driverId ? getPrimaryLeagueIdForDriver(driverId) : null;
const leagueRank = const leagueRank =
driverId && primaryLeagueId ? getLeagueRankings(driverId, primaryLeagueId) : null; driverId && primaryLeagueId ? getLeagueRankings(driverId, primaryLeagueId) : null;
const defaultStats = stats || (driverStats const defaultStats =
stats ||
(driverStats
? { ? {
totalRaces: driverStats.totalRaces, totalRaces: driverStats.totalRaces,
wins: driverStats.wins, wins: driverStats.wins,
podiums: driverStats.podiums, podiums: driverStats.podiums,
dnfs: driverStats.dnfs, dnfs: driverStats.dnfs,
avgFinish: driverStats.avgFinish, avgFinish: driverStats.avgFinish ?? 0,
completionRate: completionRate:
((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * driverStats.totalRaces > 0
100, ? ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
: 0,
} }
: null); : null);
@@ -91,17 +96,19 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"> <div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<RankBadge rank={driverStats.overallRank} size="lg" /> <RankBadge rank={driverStats.overallRank ?? 0} size="lg" />
<div> <div>
<div className="text-white font-medium text-lg">Overall Ranking</div> <div className="text-white font-medium text-lg">Overall Ranking</div>
<div className="text-sm text-gray-400"> <div className="text-sm text-gray-400">
{driverStats.overallRank} of {totalDrivers} drivers {driverStats.overallRank ?? 0} of {totalDrivers} drivers
</div> </div>
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className={`text-sm font-medium ${getPercentileColor(driverStats.percentile)}`}> <div
{getPercentileLabel(driverStats.percentile)} className={`text-sm font-medium ${getPercentileColor(driverStats.percentile ?? 0)}`}
>
{getPercentileLabel(driverStats.percentile ?? 0)}
</div> </div>
<div className="text-xs text-gray-500">Global Percentile</div> <div className="text-xs text-gray-500">Global Percentile</div>
</div> </div>
@@ -109,7 +116,9 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-charcoal-outline"> <div className="grid grid-cols-3 gap-4 pt-3 border-t border-charcoal-outline">
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold text-primary-blue">{driverStats.rating}</div> <div className="text-2xl font-bold text-primary-blue">
{driverStats.rating ?? 0}
</div>
<div className="text-xs text-gray-400">Rating</div> <div className="text-xs text-gray-400">Rating</div>
</div> </div>
<div className="text-center"> <div className="text-center">

View File

@@ -1,11 +1,13 @@
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Image from 'next/image'; import Image from 'next/image';
import type { FeedItemDTO } from '@gridpilot/social/application/dto/FeedItemDTO'; import type { FeedItemDTO } from '@gridpilot/social/application/dto/FeedItemDTO';
import { getDriverRepository, getImageService } from '@/lib/di-container'; import { getDriverRepository, getImageService } from '@/lib/di-container';
function timeAgo(timestamp: Date): string { function timeAgo(timestamp: Date | string): string {
const diffMs = Date.now() - timestamp.getTime(); const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60000); const diffMinutes = Math.floor(diffMs / 60000);
if (diffMinutes < 1) return 'Just now'; if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes} min ago`; if (diffMinutes < 60) return `${diffMinutes} min ago`;

View File

@@ -3,7 +3,7 @@
import { useRef, ReactNode } from 'react'; import { useRef, ReactNode } from 'react';
import Container from '@/components/ui/Container'; import Container from '@/components/ui/Container';
import Heading from '@/components/ui/Heading'; import Heading from '@/components/ui/Heading';
import { useScrollProgress, useParallax } from '@/hooks/useScrollProgress'; import { useParallax } from '../../hooks/useScrollProgress';
interface AlternatingSectionProps { interface AlternatingSectionProps {
heading: string; heading: string;

View File

@@ -2,7 +2,6 @@
import { useRef } from 'react'; import { useRef } from 'react';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { useScrollProgress } from '@/hooks/useScrollProgress';
export default function DiscordCTA() { export default function DiscordCTA() {
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#'; const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';

View File

@@ -11,7 +11,6 @@ import TeamCompetitionMockup from '@/components/mockups/TeamCompetitionMockup';
import ProtestWorkflowMockup from '@/components/mockups/ProtestWorkflowMockup'; import ProtestWorkflowMockup from '@/components/mockups/ProtestWorkflowMockup';
import LeagueDiscoveryMockup from '@/components/mockups/LeagueDiscoveryMockup'; import LeagueDiscoveryMockup from '@/components/mockups/LeagueDiscoveryMockup';
import DriverProfileMockup from '@/components/mockups/DriverProfileMockup'; import DriverProfileMockup from '@/components/mockups/DriverProfileMockup';
import { useScrollProgress } from '@/hooks/useScrollProgress';
const features = [ const features = [
{ {

View File

@@ -1,8 +1,6 @@
'use client'; 'use client';
import Image from 'next/image'; import Image from 'next/image';
import { useRef } from 'react';
import { useScrollProgress } from '@/hooks/useScrollProgress';
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || 'https://discord.gg/gridpilot'; const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || 'https://discord.gg/gridpilot';
const xUrl = process.env.NEXT_PUBLIC_X_URL || '#'; const xUrl = process.env.NEXT_PUBLIC_X_URL || '#';

View File

@@ -4,7 +4,7 @@ import { useRef } from 'react';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Container from '@/components/ui/Container'; import Container from '@/components/ui/Container';
import Heading from '@/components/ui/Heading'; import Heading from '@/components/ui/Heading';
import { useScrollProgress, useParallax } from '@/hooks/useScrollProgress'; import { useParallax } from '../../hooks/useScrollProgress';
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#'; const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';

View File

@@ -156,7 +156,8 @@ function getDefaultSeasonStartDate(): string {
const daysUntilSaturday = (6 - now.getDay() + 7) % 7 || 7; const daysUntilSaturday = (6 - now.getDay() + 7) % 7 || 7;
const nextSaturday = new Date(now); const nextSaturday = new Date(now);
nextSaturday.setDate(now.getDate() + daysUntilSaturday); nextSaturday.setDate(now.getDate() + daysUntilSaturday);
return nextSaturday.toISOString().split('T')[0]; const [datePart] = nextSaturday.toISOString().split('T');
return datePart ?? '';
} }
function createDefaultForm(): LeagueConfigFormModel { function createDefaultForm(): LeagueConfigFormModel {
@@ -172,8 +173,6 @@ function createDefaultForm(): LeagueConfigFormModel {
structure: { structure: {
mode: 'solo', mode: 'solo',
maxDrivers: 24, maxDrivers: 24,
maxTeams: undefined,
driversPerTeam: undefined,
multiClassEnabled: false, multiClassEnabled: false,
}, },
championships: { championships: {
@@ -193,7 +192,7 @@ function createDefaultForm(): LeagueConfigFormModel {
timings: { timings: {
practiceMinutes: 20, practiceMinutes: 20,
qualifyingMinutes: 30, qualifyingMinutes: 30,
sprintRaceMinutes: defaultPatternId === 'sprint-main-driver' ? 20 : undefined, sprintRaceMinutes: 20,
mainRaceMinutes: 40, mainRaceMinutes: 40,
sessionCount: 2, sessionCount: 2,
roundsPlanned: 8, roundsPlanned: 8,
@@ -265,12 +264,13 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
const query = getListLeagueScoringPresetsQuery(); const query = getListLeagueScoringPresetsQuery();
const result = await query.execute(); const result = await query.execute();
setPresets(result); setPresets(result);
if (result.length > 0) { const firstPreset = result[0];
if (firstPreset) {
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
scoring: { scoring: {
...prev.scoring, ...prev.scoring,
patternId: prev.scoring.patternId || result[0].id, patternId: prev.scoring.patternId || firstPreset.id,
customScoringEnabled: prev.scoring.customScoringEnabled ?? false, customScoringEnabled: prev.scoring.customScoringEnabled ?? false,
}, },
})); }));
@@ -338,7 +338,10 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
} }
setLoading(true); setLoading(true);
setErrors((prev) => ({ ...prev, submit: undefined })); setErrors((prev) => {
const { submit, ...rest } = prev;
return rest;
});
try { try {
const result = await createLeagueFromConfig(form); const result = await createLeagueFromConfig(form);
@@ -577,7 +580,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
<LeagueBasicsSection <LeagueBasicsSection
form={form} form={form}
onChange={setForm} onChange={setForm}
errors={errors.basics} errors={errors.basics ?? {}}
/> />
</div> </div>
)} )}
@@ -587,7 +590,11 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
<LeagueVisibilitySection <LeagueVisibilitySection
form={form} form={form}
onChange={setForm} onChange={setForm}
errors={errors.basics} errors={
errors.basics?.visibility
? { visibility: errors.basics.visibility }
: {}
}
/> />
</div> </div>
)} )}
@@ -607,7 +614,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
<LeagueTimingsSection <LeagueTimingsSection
form={form} form={form}
onChange={setForm} onChange={setForm}
errors={errors.timings} errors={errors.timings ?? {}}
/> />
</div> </div>
)} )}
@@ -619,7 +626,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
scoring={form.scoring} scoring={form.scoring}
presets={presets} presets={presets}
readOnly={presetsLoading} readOnly={presetsLoading}
patternError={errors.scoring?.patternId} patternError={errors.scoring?.patternId ?? ''}
onChangePatternId={handleScoringPresetChange} onChangePatternId={handleScoringPresetChange}
onToggleCustomScoring={() => onToggleCustomScoring={() =>
setForm((prev) => ({ setForm((prev) => ({

View File

@@ -32,23 +32,18 @@ export default function JoinLeagueButton({
const membershipRepo = getLeagueMembershipRepository(); const membershipRepo = getLeagueMembershipRepository();
if (isInviteOnly) { if (isInviteOnly) {
// For alpha, treat "request to join" as creating a pending membership const existing = await membershipRepo.getMembership(leagueId, currentDriverId);
const pending = await membershipRepo.getMembership(leagueId, currentDriverId); if (existing) {
if (pending) {
throw new Error('Already a member or have a pending request'); throw new Error('Already a member or have a pending request');
} }
await membershipRepo.saveMembership({ throw new Error(
leagueId, 'Requesting to join invite-only leagues is not available in this alpha build.',
driverId: currentDriverId, );
role: 'member', }
status: 'pending',
joinedAt: new Date(),
});
} else {
const useCase = getJoinLeagueUseCase(); const useCase = getJoinLeagueUseCase();
await useCase.execute({ leagueId, driverId: currentDriverId }); await useCase.execute({ leagueId, driverId: currentDriverId });
}
onMembershipChange?.(); onMembershipChange?.();
setShowConfirmDialog(false); setShowConfirmDialog(false);

View File

@@ -20,6 +20,7 @@ import {
type LeagueAdminProtestsViewModel, type LeagueAdminProtestsViewModel,
} from '@/lib/presenters/LeagueAdminPresenter'; } from '@/lib/presenters/LeagueAdminPresenter';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import type { LeagueSummaryViewModel } from '@/lib/presenters/LeagueAdminPresenter';
import { LeagueBasicsSection } from './LeagueBasicsSection'; import { LeagueBasicsSection } from './LeagueBasicsSection';
import { LeagueStructureSection } from './LeagueStructureSection'; import { LeagueStructureSection } from './LeagueStructureSection';
import { LeagueScoringSection } from './LeagueScoringSection'; import { LeagueScoringSection } from './LeagueScoringSection';
@@ -37,13 +38,7 @@ import { AlertTriangle, CheckCircle, Clock, XCircle, Flag, Calendar, User, Dolla
type JoinRequest = LeagueJoinRequestViewModel; type JoinRequest = LeagueJoinRequestViewModel;
interface LeagueAdminProps { interface LeagueAdminProps {
league: { league: LeagueSummaryViewModel;
id: string;
ownerId: string;
settings: {
pointsSystem: string;
};
};
onLeagueUpdate?: () => void; onLeagueUpdate?: () => void;
} }
@@ -83,7 +78,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
useEffect(() => { useEffect(() => {
async function loadOwner() { async function loadOwner() {
try { try {
const summary = await loadLeagueOwnerSummary(league); const summary = await loadLeagueOwnerSummary({ ownerId: league.ownerId });
setOwnerSummary(summary); setOwnerSummary(summary);
} catch (err) { } catch (err) {
console.error('Failed to load league owner:', err); console.error('Failed to load league owner:', err);

View File

@@ -3,9 +3,7 @@
import React from 'react'; import React from 'react';
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react'; import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import type { import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
LeagueConfigFormModel,
} from '@gridpilot/racing/application';
interface LeagueBasicsSectionProps { interface LeagueBasicsSectionProps {
form: LeagueConfigFormModel; form: LeagueConfigFormModel;

View File

@@ -259,13 +259,19 @@ export function LeagueDropSection({
if (disabled || !onChange) return; if (disabled || !onChange) return;
const option = DROP_OPTIONS.find((o) => o.value === strategy); const option = DROP_OPTIONS.find((o) => o.value === strategy);
onChange({ const next: LeagueConfigFormModel = {
...form, ...form,
dropPolicy: { dropPolicy:
strategy === 'none'
? {
strategy, strategy,
n: strategy === 'none' ? undefined : (dropPolicy.n ?? option?.defaultN), }
: {
strategy,
n: dropPolicy.n ?? option?.defaultN ?? 1,
}, },
}); };
onChange(next);
}; };
const handleNChange = (delta: number) => { const handleNChange = (delta: number) => {

View File

@@ -136,7 +136,7 @@ export default function LeagueMembers({
<label className="text-sm text-gray-400">Sort by:</label> <label className="text-sm text-gray-400">Sort by:</label>
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)} onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue" className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
> >
<option value="rating">Rating</option> <option value="rating">Rating</option>

View File

@@ -358,19 +358,32 @@ export function LeagueScoringSection({
}); });
}; };
const patternPanel = ( const patternProps: ScoringPatternSectionProps = {
<ScoringPatternSection scoring: form.scoring,
scoring={form.scoring} presets,
presets={presets} readOnly: !!readOnly,
readOnly={readOnly} };
onChangePatternId={!readOnly && onChange ? handleSelectPreset : undefined}
onToggleCustomScoring={disabled ? undefined : handleToggleCustomScoring}
/>
);
const championshipsPanel = ( if (!readOnly && onChange) {
<ChampionshipsSection form={form} onChange={onChange} readOnly={readOnly} /> patternProps.onChangePatternId = handleSelectPreset;
); }
if (!disabled) {
patternProps.onToggleCustomScoring = handleToggleCustomScoring;
}
const patternPanel = <ScoringPatternSection {...patternProps} />;
const championshipsProps: ChampionshipsSectionProps = {
form,
readOnly: !!readOnly,
};
if (onChange) {
championshipsProps.onChange = onChange;
}
const championshipsPanel = <ChampionshipsSection {...championshipsProps} />;
if (patternOnly) { if (patternOnly) {
return <div>{patternPanel}</div>; return <div>{patternPanel}</div>;

View File

@@ -11,6 +11,7 @@ import {
getRejectSponsorshipRequestUseCase, getRejectSponsorshipRequestUseCase,
getSeasonRepository, getSeasonRepository,
} from '@/lib/di-container'; } from '@/lib/di-container';
import { PendingSponsorshipRequestsPresenter } from '@/lib/presenters/PendingSponsorshipRequestsPresenter';
import { useEffectiveDriverId } from '@/lib/currentDriver'; import { useEffectiveDriverId } from '@/lib/currentDriver';
interface SponsorshipSlot { interface SponsorshipSlot {
@@ -72,11 +73,18 @@ export function LeagueSponsorshipsSection({
setRequestsLoading(true); setRequestsLoading(true);
try { try {
const useCase = getGetPendingSponsorshipRequestsUseCase(); const useCase = getGetPendingSponsorshipRequestsUseCase();
await useCase.execute({ const presenter = new PendingSponsorshipRequestsPresenter();
await useCase.execute(
{
entityType: 'season', entityType: 'season',
entityId: seasonId, entityId: seasonId,
}); },
setPendingRequests(result.requests); presenter,
);
const viewModel = presenter.getViewModel();
setPendingRequests(viewModel?.requests ?? []);
} catch (err) { } catch (err) {
console.error('Failed to load pending requests:', err); console.error('Failed to load pending requests:', err);
} finally { } finally {
@@ -108,7 +116,7 @@ export function LeagueSponsorshipsSection({
await useCase.execute({ await useCase.execute({
requestId, requestId,
respondedBy: currentDriverId, respondedBy: currentDriverId,
reason, ...(reason ? { reason } : {}),
}); });
await loadPendingRequests(); await loadPendingRequests();
} catch (err) { } catch (err) {
@@ -118,17 +126,22 @@ export function LeagueSponsorshipsSection({
}; };
const handleEditPrice = (index: number) => { const handleEditPrice = (index: number) => {
const slot = slots[index];
if (!slot) return;
setEditingIndex(index); setEditingIndex(index);
setTempPrice(slots[index].price.toString()); setTempPrice(slot.price.toString());
}; };
const handleSavePrice = (index: number) => { const handleSavePrice = (index: number) => {
const price = parseFloat(tempPrice); const price = parseFloat(tempPrice);
if (!isNaN(price) && price > 0) { if (!isNaN(price) && price > 0) {
const updated = [...slots]; const updated = [...slots];
updated[index].price = price; const slot = updated[index];
if (slot) {
slot.price = price;
setSlots(updated); setSlots(updated);
} }
}
setEditingIndex(null); setEditingIndex(null);
setTempPrice(''); setTempPrice('');
}; };

View File

@@ -159,13 +159,10 @@ export function LeagueStructureSection({
} }
if (nextStructure.mode === 'solo') { if (nextStructure.mode === 'solo') {
const { maxTeams, driversPerTeam, ...restStructure } = nextStructure;
nextForm = { nextForm = {
...nextForm, ...nextForm,
structure: { structure: restStructure,
...nextStructure,
maxTeams: undefined,
driversPerTeam: undefined,
},
}; };
} }
@@ -178,8 +175,6 @@ export function LeagueStructureSection({
updateStructure({ updateStructure({
mode: 'solo', mode: 'solo',
maxDrivers: structure.maxDrivers || 24, maxDrivers: structure.maxDrivers || 24,
maxTeams: undefined,
driversPerTeam: undefined,
}); });
} else { } else {
const maxTeams = structure.maxTeams ?? 12; const maxTeams = structure.maxTeams ?? 12;

View File

@@ -38,7 +38,10 @@ const TIME_ZONES = [
{ value: 'Australia/Sydney', label: 'Sydney (AU)', icon: Globe }, { value: 'Australia/Sydney', label: 'Sydney (AU)', icon: Globe },
]; ];
type RecurrenceStrategy = NonNullable<LeagueConfigFormModel['timings']>['recurrenceStrategy']; type RecurrenceStrategy = Exclude<
NonNullable<LeagueConfigFormModel['timings']>['recurrenceStrategy'],
undefined
>;
interface LeagueTimingsSectionProps { interface LeagueTimingsSectionProps {
form: LeagueConfigFormModel; form: LeagueConfigFormModel;
@@ -96,12 +99,16 @@ function RaceDayPreview({
const effectiveRaceTime = raceTime || '20:00'; const effectiveRaceTime = raceTime || '20:00';
const getStartTime = (sessionIndex: number) => { const getStartTime = (sessionIndex: number) => {
const [hours, minutes] = effectiveRaceTime.split(':').map(Number); const [hoursStr, minutesStr] = effectiveRaceTime.split(':');
const hours = Number(hoursStr ?? '0');
const minutes = Number(minutesStr ?? '0');
let totalMinutes = hours * 60 + minutes; let totalMinutes = hours * 60 + minutes;
const active = allSessions.filter(s => s.active); const active = allSessions.filter((s) => s.active);
for (let i = 0; i < sessionIndex; i++) { for (let i = 0; i < sessionIndex; i++) {
totalMinutes += active[i].duration + 10; // 10 min break between sessions const session = active[i];
if (!session) continue;
totalMinutes += session.duration + 10; // 10 min break between sessions
} }
const h = Math.floor(totalMinutes / 60) % 24; const h = Math.floor(totalMinutes / 60) % 24;
@@ -231,10 +238,31 @@ function YearCalendarPreview({
}) { }) {
// JavaScript getDay(): 0=Sunday, 1=Monday, 2=Tuesday, etc. // JavaScript getDay(): 0=Sunday, 1=Monday, 2=Tuesday, etc.
const dayMap: Record<Weekday, number> = { const dayMap: Record<Weekday, number> = {
'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6 Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
}; };
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
] as const;
const getMonthLabel = (index: number): string => months[index] ?? '—';
// Parse start and end dates // Parse start and end dates
const seasonStart = useMemo(() => { const seasonStart = useMemo(() => {
@@ -279,7 +307,8 @@ function YearCalendarPreview({
const spacing = totalPossible / rounds; const spacing = totalPossible / rounds;
for (let i = 0; i < rounds; i++) { for (let i = 0; i < rounds; i++) {
const index = Math.min(Math.floor(i * spacing), totalPossible - 1); const index = Math.min(Math.floor(i * spacing), totalPossible - 1);
dates.push(allPossibleDays[index]); const chosen = allPossibleDays[index]!;
dates.push(chosen);
} }
} else { } else {
// Not enough days - use all available // Not enough days - use all available
@@ -380,7 +409,7 @@ function YearCalendarPreview({
} }
view.push({ view.push({
month: months[targetMonth], month: months[targetMonth] ?? '—',
monthIndex: targetMonth, monthIndex: targetMonth,
year: targetYear, year: targetYear,
days days
@@ -391,8 +420,8 @@ function YearCalendarPreview({
} }
// Get the range of months that contain races // Get the range of months that contain races
const firstRaceDate = raceDates[0]; const firstRaceDate = raceDates[0]!;
const lastRaceDate = raceDates[raceDates.length - 1]; const lastRaceDate = raceDates[raceDates.length - 1]!;
// Start from first race month, show 12 months total // Start from first race month, show 12 months total
const startMonth = firstRaceDate.getMonth(); const startMonth = firstRaceDate.getMonth();
@@ -414,18 +443,19 @@ function YearCalendarPreview({
rd.getDate() === date.getDate() rd.getDate() === date.getDate()
); );
const isRace = raceIndex >= 0; const isRace = raceIndex >= 0;
const raceNumber = isRace ? raceIndex + 1 : undefined;
days.push({ days.push({
date, date,
isRace, isRace,
dayOfMonth: day, dayOfMonth: day,
isStart: isSeasonStartDate(date), isStart: isSeasonStartDate(date),
isEnd: isSeasonEndDate(date), isEnd: isSeasonEndDate(date),
raceNumber: isRace ? raceIndex + 1 : undefined, ...(raceNumber !== undefined ? { raceNumber } : {}),
}); });
} }
view.push({ view.push({
month: months[targetMonth], month: months[targetMonth] ?? '—',
monthIndex: targetMonth, monthIndex: targetMonth,
year: targetYear, year: targetYear,
days days
@@ -438,8 +468,12 @@ function YearCalendarPreview({
// Calculate season stats // Calculate season stats
const firstRace = raceDates[0]; const firstRace = raceDates[0];
const lastRace = raceDates[raceDates.length - 1]; const lastRace = raceDates[raceDates.length - 1];
const seasonDurationWeeks = firstRace && lastRace const seasonDurationWeeks =
? Math.ceil((lastRace.getTime() - firstRace.getTime()) / (7 * 24 * 60 * 60 * 1000)) firstRace && lastRace
? Math.ceil(
(lastRace.getTime() - firstRace.getTime()) /
(7 * 24 * 60 * 60 * 1000),
)
: 0; : 0;
return ( return (
@@ -525,12 +559,18 @@ function YearCalendarPreview({
<div className="text-[9px] text-gray-500">Rounds</div> <div className="text-[9px] text-gray-500">Rounds</div>
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-lg font-bold text-white">{seasonDurationWeeks || '—'}</div> <div className="text-lg font-bold text-white">
{seasonDurationWeeks || '—'}
</div>
<div className="text-[9px] text-gray-500">Weeks</div> <div className="text-[9px] text-gray-500">Weeks</div>
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-lg font-bold text-primary-blue"> <div className="text-lg font-bold text-primary-blue">
{firstRace ? `${months[firstRace.getMonth()]}${months[lastRace?.getMonth() ?? 0]}` : '—'} {firstRace && lastRace
? `${getMonthLabel(firstRace.getMonth())}${getMonthLabel(
lastRace.getMonth(),
)}`
: '—'}
</div> </div>
<div className="text-[9px] text-gray-500">Duration</div> <div className="text-[9px] text-gray-500">Duration</div>
</div> </div>
@@ -986,7 +1026,9 @@ export function LeagueTimingsSection({
onClick={() => onClick={() =>
updateTimings({ updateTimings({
recurrenceStrategy: opt.id as RecurrenceStrategy, recurrenceStrategy: opt.id as RecurrenceStrategy,
intervalWeeks: opt.id === 'everyNWeeks' ? 2 : undefined, ...(opt.id === 'everyNWeeks'
? { intervalWeeks: 2 }
: {}),
}) })
} }
className={` className={`
@@ -1054,7 +1096,7 @@ export function LeagueTimingsSection({
<Input <Input
type="date" type="date"
value={timings.seasonStartDate ?? ''} value={timings.seasonStartDate ?? ''}
onChange={(e) => updateTimings({ seasonStartDate: e.target.value || undefined })} onChange={(e) => updateTimings({ seasonStartDate: e.target.value })}
className="bg-iron-gray/30" className="bg-iron-gray/30"
/> />
</div> </div>
@@ -1066,7 +1108,7 @@ export function LeagueTimingsSection({
<Input <Input
type="date" type="date"
value={timings.seasonEndDate ?? ''} value={timings.seasonEndDate ?? ''}
onChange={(e) => updateTimings({ seasonEndDate: e.target.value || undefined })} onChange={(e) => updateTimings({ seasonEndDate: e.target.value })}
className="bg-iron-gray/30" className="bg-iron-gray/30"
/> />
</div> </div>
@@ -1086,7 +1128,7 @@ export function LeagueTimingsSection({
<Input <Input
type="time" type="time"
value={timings.raceStartTime ?? '20:00'} value={timings.raceStartTime ?? '20:00'}
onChange={(e) => updateTimings({ raceStartTime: e.target.value || undefined })} onChange={(e) => updateTimings({ raceStartTime: e.target.value })}
className="bg-iron-gray/30" className="bg-iron-gray/30"
/> />
</div> </div>
@@ -1214,28 +1256,45 @@ export function LeagueTimingsSection({
{/* Preview content */} {/* Preview content */}
<div className="p-4 min-h-[300px]"> <div className="p-4 min-h-[300px]">
{previewTab === 'day' && ( {previewTab === 'day' && (() => {
const sprintMinutes = showSprint
? timings.sprintRaceMinutes ?? 20
: undefined;
return (
<RaceDayPreview <RaceDayPreview
template={showSprint ? 'sprintFeature' : 'feature'} template={showSprint ? 'sprintFeature' : 'feature'}
practiceMin={timings.practiceMinutes ?? 20} practiceMin={timings.practiceMinutes ?? 20}
qualifyingMin={timings.qualifyingMinutes ?? 15} qualifyingMin={timings.qualifyingMinutes ?? 15}
sprintMin={showSprint ? (timings.sprintRaceMinutes ?? 20) : undefined} {...(sprintMinutes !== undefined
? { sprintMin: sprintMinutes }
: {})}
mainRaceMin={timings.mainRaceMinutes ?? 40} mainRaceMin={timings.mainRaceMinutes ?? 40}
raceTime={timings.raceStartTime} {...(timings.raceStartTime
? { raceTime: timings.raceStartTime }
: {})}
/> />
)} );
})()}
{previewTab === 'year' && ( {previewTab === 'year' && (
<YearCalendarPreview <YearCalendarPreview
weekdays={weekdays} weekdays={weekdays}
frequency={recurrenceStrategy} frequency={recurrenceStrategy}
rounds={timings.roundsPlanned ?? 8} rounds={timings.roundsPlanned ?? 8}
startDate={timings.seasonStartDate} {...(timings.seasonStartDate
endDate={timings.seasonEndDate} ? { startDate: timings.seasonStartDate }
: {})}
{...(timings.seasonEndDate
? { endDate: timings.seasonEndDate }
: {})}
/> />
)} )}
{previewTab === 'stats' && ( {previewTab === 'stats' && (() => {
const sprintMinutes = showSprint
? timings.sprintRaceMinutes ?? 20
: undefined;
return (
<SeasonStatsPreview <SeasonStatsPreview
rounds={timings.roundsPlanned ?? 8} rounds={timings.roundsPlanned ?? 8}
weekdays={weekdays} weekdays={weekdays}
@@ -1243,10 +1302,13 @@ export function LeagueTimingsSection({
weekendTemplate={showSprint ? 'sprintFeature' : 'feature'} weekendTemplate={showSprint ? 'sprintFeature' : 'feature'}
practiceMin={timings.practiceMinutes ?? 20} practiceMin={timings.practiceMinutes ?? 20}
qualifyingMin={timings.qualifyingMinutes ?? 15} qualifyingMin={timings.qualifyingMinutes ?? 15}
sprintMin={showSprint ? (timings.sprintRaceMinutes ?? 20) : undefined} {...(sprintMinutes !== undefined
? { sprintMin: sprintMinutes }
: {})}
mainRaceMin={timings.mainRaceMinutes ?? 40} mainRaceMin={timings.mainRaceMinutes ?? 40}
/> />
)} );
})()}
</div> </div>
</div> </div>

View File

@@ -229,7 +229,7 @@ export default function ScheduleRaceForm({
</label> </label>
<select <select
value={formData.sessionType} value={formData.sessionType}
onChange={(e) => handleChange('sessionType', e.target.value as SessionType)} onChange={(e) => handleChange('sessionType', e.target.value)}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue" className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
> >
<option value="practice">Practice</option> <option value="practice">Practice</option>

View File

@@ -161,7 +161,7 @@ export default function DriverProfileMockup() {
value={stat.value} value={stat.value}
shouldReduceMotion={shouldReduceMotion ?? false} shouldReduceMotion={shouldReduceMotion ?? false}
delay={index * 0.1} delay={index * 0.1}
suffix={stat.suffix} suffix={stat.suffix ?? ''}
/> />
<div className="text-[8px] sm:text-[10px] md:text-xs text-gray-400 mt-0.5">{stat.label}</div> <div className="text-[8px] sm:text-[10px] md:text-xs text-gray-400 mt-0.5">{stat.label}</div>
</motion.div> </motion.div>

View File

@@ -257,7 +257,10 @@ export default function OnboardingWizard() {
generatedAvatars: [], generatedAvatars: [],
selectedAvatarIndex: null, selectedAvatarIndex: null,
}); });
setErrors({ ...errors, facePhoto: undefined }); setErrors((prev) => {
const { facePhoto, ...rest } = prev;
return rest;
});
// Validate face // Validate face
await validateFacePhoto(base64); await validateFacePhoto(base64);
@@ -267,7 +270,10 @@ export default function OnboardingWizard() {
const validateFacePhoto = async (photoData: string) => { const validateFacePhoto = async (photoData: string) => {
setAvatarInfo(prev => ({ ...prev, isValidating: true })); setAvatarInfo(prev => ({ ...prev, isValidating: true }));
setErrors(prev => ({ ...prev, facePhoto: undefined })); setErrors(prev => {
const { facePhoto, ...rest } = prev;
return rest;
});
try { try {
const response = await fetch('/api/avatar/validate-face', { const response = await fetch('/api/avatar/validate-face', {
@@ -300,7 +306,10 @@ export default function OnboardingWizard() {
} }
setAvatarInfo(prev => ({ ...prev, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null })); setAvatarInfo(prev => ({ ...prev, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null }));
setErrors(prev => ({ ...prev, avatar: undefined })); setErrors(prev => {
const { avatar, ...rest } = prev;
return rest;
});
try { try {
const response = await fetch('/api/avatar/generate', { const response = await fetch('/api/avatar/generate', {
@@ -490,7 +499,7 @@ export default function OnboardingWizard() {
setPersonalInfo({ ...personalInfo, country: value }) setPersonalInfo({ ...personalInfo, country: value })
} }
error={!!errors.country} error={!!errors.country}
errorMessage={errors.country} errorMessage={errors.country ?? ''}
disabled={loading} disabled={loading}
/> />
</div> </div>
@@ -600,17 +609,24 @@ export default function OnboardingWizard() {
{/* Preview area */} {/* Preview area */}
<div className="w-32 flex flex-col items-center justify-center"> <div className="w-32 flex flex-col items-center justify-center">
<div className="w-24 h-24 rounded-xl bg-iron-gray border border-charcoal-outline flex items-center justify-center overflow-hidden"> <div className="w-24 h-24 rounded-xl bg-iron-gray border border-charcoal-outline flex items-center justify-center overflow-hidden">
{avatarInfo.selectedAvatarIndex !== null && avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex] ? ( {(() => {
const selectedAvatarUrl =
avatarInfo.selectedAvatarIndex !== null
? avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex]
: undefined;
if (!selectedAvatarUrl) {
return <User className="w-8 h-8 text-gray-600" />;
}
return (
<Image <Image
src={avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex]} src={selectedAvatarUrl}
alt="Selected avatar" alt="Selected avatar"
width={96} width={96}
height={96} height={96}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( );
<User className="w-8 h-8 text-gray-600" /> })()}
)}
</div> </div>
<p className="text-xs text-gray-500 mt-2 text-center">Your avatar</p> <p className="text-xs text-gray-500 mt-2 text-center">Your avatar</p>
</div> </div>

View File

@@ -3,8 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { getFileProtestUseCase, getDriverRepository } from '@/lib/di-container'; import { getFileProtestUseCase } from '@/lib/di-container';
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
import type { ProtestIncident } from '@gridpilot/racing/domain/entities/Protest'; import type { ProtestIncident } from '@gridpilot/racing/domain/entities/Protest';
import { import {
AlertTriangle, AlertTriangle,
@@ -17,13 +16,18 @@ import {
CheckCircle2, CheckCircle2,
} from 'lucide-react'; } from 'lucide-react';
type ProtestParticipant = {
id: string;
name: string;
};
interface FileProtestModalProps { interface FileProtestModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
raceId: string; raceId: string;
leagueId?: string; leagueId?: string;
protestingDriverId: string; protestingDriverId: string;
participants: Driver[]; participants: ProtestParticipant[];
} }
export default function FileProtestModal({ export default function FileProtestModal({
@@ -70,18 +74,26 @@ export default function FileProtestModal({
const incident: ProtestIncident = { const incident: ProtestIncident = {
lap: parseInt(lap, 10), lap: parseInt(lap, 10),
timeInRace: timeInRace ? parseInt(timeInRace, 10) : undefined,
description: description.trim(), description: description.trim(),
...(timeInRace
? { timeInRace: parseInt(timeInRace, 10) }
: {}),
}; };
await useCase.execute({ const command = {
raceId, raceId,
protestingDriverId, protestingDriverId,
accusedDriverId, accusedDriverId,
incident, incident,
comment: comment.trim() || undefined, ...(comment.trim()
proofVideoUrl: proofVideoUrl.trim() || undefined, ? { comment: comment.trim() }
}); : {}),
...(proofVideoUrl.trim()
? { proofVideoUrl: proofVideoUrl.trim() }
: {}),
};
await useCase.execute(command);
setStep('success'); setStep('success');
} catch (err) { } catch (err) {

View File

@@ -39,7 +39,8 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
throw new Error('CSV file is empty or invalid'); throw new Error('CSV file is empty or invalid');
} }
const header = lines[0].toLowerCase().split(',').map((h) => h.trim()); const headerLine = lines[0]!;
const header = headerLine.toLowerCase().split(',').map((h) => h.trim());
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition']; const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
for (const field of requiredFields) { for (const field of requiredFields) {
@@ -50,7 +51,11 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
const rows: CSVRow[] = []; const rows: CSVRow[] = [];
for (let i = 1; i < lines.length; i++) { for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map((v) => v.trim()); const line = lines[i];
if (!line) {
continue;
}
const values = line.split(',').map((v) => v.trim());
if (values.length !== header.length) { if (values.length !== header.length) {
throw new Error( throw new Error(
@@ -63,11 +68,11 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
row[field] = values[index] ?? ''; row[field] = values[index] ?? '';
}); });
const driverId = row.driverid; const driverId = row['driverid'] ?? '';
const position = parseInt(row.position, 10); const position = parseInt(row['position'] ?? '', 10);
const fastestLap = parseFloat(row.fastestlap); const fastestLap = parseFloat(row['fastestlap'] ?? '');
const incidents = parseInt(row.incidents, 10); const incidents = parseInt(row['incidents'] ?? '', 10);
const startPosition = parseInt(row.startposition, 10); const startPosition = parseInt(row['startposition'] ?? '', 10);
if (!driverId || driverId.length === 0) { if (!driverId || driverId.length === 0) {
throw new Error(`Row ${i}: driverId is required`); throw new Error(`Row ${i}: driverId is required`);

View File

@@ -38,9 +38,9 @@ interface ResultsTableProps {
results: ResultDTO[]; results: ResultDTO[];
drivers: DriverDTO[]; drivers: DriverDTO[];
pointsSystem: Record<number, number>; pointsSystem: Record<number, number>;
fastestLapTime?: number; fastestLapTime?: number | undefined;
penalties?: PenaltyData[]; penalties?: PenaltyData[];
currentDriverId?: string; currentDriverId?: string | undefined;
} }
export default function ResultsTable({ export default function ResultsTable({

View File

@@ -5,11 +5,19 @@ import Button from '@/components/ui/Button';
import { import {
getJoinTeamUseCase, getJoinTeamUseCase,
getLeaveTeamUseCase, getLeaveTeamUseCase,
getGetDriverTeamUseCase,
getTeamMembershipRepository, getTeamMembershipRepository,
} from '@/lib/di-container'; } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver'; import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { TeamMembership } from '@gridpilot/racing';
type TeamMembershipStatus = 'active' | 'pending' | 'inactive';
interface TeamMembership {
teamId: string;
driverId: string;
role: 'owner' | 'manager' | 'driver';
status: TeamMembershipStatus;
joinedAt: Date | string;
}
interface JoinTeamButtonProps { interface JoinTeamButtonProps {
teamId: string; teamId: string;
@@ -25,25 +33,12 @@ export default function JoinTeamButton({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
const [membership, setMembership] = useState<TeamMembership | null>(null); const [membership, setMembership] = useState<TeamMembership | null>(null);
const [currentTeamName, setCurrentTeamName] = useState<string | null>(null);
const [currentTeamId, setCurrentTeamId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
const membershipRepo = getTeamMembershipRepository(); const membershipRepo = getTeamMembershipRepository();
const m = await membershipRepo.getMembership(teamId, currentDriverId); const m = await membershipRepo.getMembership(teamId, currentDriverId);
setMembership(m); setMembership(m as TeamMembership | null);
const driverTeamUseCase = getGetDriverTeamUseCase();
await driverTeamUseCase.execute({ driverId: currentDriverId });
const viewModel = driverTeamUseCase.presenter.getViewModel();
if (viewModel.result) {
setCurrentTeamId(viewModel.result.team.id);
setCurrentTeamName(viewModel.result.team.name);
} else {
setCurrentTeamId(null);
setCurrentTeamName(null);
}
}; };
void load(); void load();
}, [teamId, currentDriverId]); }, [teamId, currentDriverId]);
@@ -117,15 +112,6 @@ export default function JoinTeamButton({
); );
} }
// Already on another team
if (currentTeamId && currentTeamId !== teamId) {
return (
<Button variant="secondary" disabled>
Already on {currentTeamName}
</Button>
);
}
// Can join // Can join
return ( return (
<Button <Button

View File

@@ -39,7 +39,13 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
try { try {
const viewModel = await loadTeamAdminViewModel(team as any); const viewModel = await loadTeamAdminViewModel({
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
ownerId: team.ownerId,
});
setJoinRequests(viewModel.requests); setJoinRequests(viewModel.requests);
const driversById: Record<string, DriverDTO> = {}; const driversById: Record<string, DriverDTO> = {};

View File

@@ -29,9 +29,9 @@ interface TeamCardProps {
totalRaces?: number; totalRaces?: number;
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro'; performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
isRecruiting?: boolean; isRecruiting?: boolean;
specialization?: 'endurance' | 'sprint' | 'mixed'; specialization?: 'endurance' | 'sprint' | 'mixed' | undefined;
region?: string; region?: string;
languages?: string[]; languages?: string[] | undefined;
leagues?: string[]; leagues?: string[];
onClick?: () => void; onClick?: () => void;
} }

View File

@@ -7,8 +7,7 @@ import {
getTeamRosterViewModel, getTeamRosterViewModel,
type TeamRosterViewModel, type TeamRosterViewModel,
} from '@/lib/presenters/TeamRosterPresenter'; } from '@/lib/presenters/TeamRosterPresenter';
import type { TeamRole } from '@gridpilot/racing/domain/types/TeamMembership';
type TeamRole = 'owner' | 'manager' | 'driver';
interface TeamMembershipSummary { interface TeamMembershipSummary {
driverId: string; driverId: string;
@@ -39,7 +38,14 @@ export default function TeamRoster({
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
try { try {
const vm = await getTeamRosterViewModel(memberships); const fullMemberships = memberships.map((m) => ({
teamId,
driverId: m.driverId,
role: m.role,
joinedAt: m.joinedAt,
status: 'active' as const,
}));
const vm = await getTeamRosterViewModel(fullMemberships);
setViewModel(vm); setViewModel(vm);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -64,6 +70,19 @@ export default function TeamRoster({
return role.charAt(0).toUpperCase() + role.slice(1); return role.charAt(0).toUpperCase() + role.slice(1);
}; };
function getRoleOrder(role: TeamRole): number {
switch (role) {
case 'owner':
return 0;
case 'manager':
return 1;
case 'driver':
return 2;
default:
return 3;
}
}
const sortedMembers = viewModel const sortedMembers = viewModel
? [...viewModel.members].sort((a, b) => { ? [...viewModel.members].sort((a, b) => {
switch (sortBy) { switch (sortBy) {
@@ -73,8 +92,7 @@ export default function TeamRoster({
return ratingB - ratingA; return ratingB - ratingA;
} }
case 'role': { case 'role': {
const roleOrder: Record<TeamRole, number> = { owner: 0, manager: 1, driver: 2 }; return getRoleOrder(a.role) - getRoleOrder(b.role);
return roleOrder[a.role] - roleOrder[b.role];
} }
case 'name': { case 'name': {
return a.driver.name.localeCompare(b.driver.name); return a.driver.name.localeCompare(b.driver.name);
@@ -110,7 +128,7 @@ export default function TeamRoster({
<label className="text-sm text-gray-400">Sort by:</label> <label className="text-sm text-gray-400">Sort by:</label>
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)} onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue" className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
> >
<option value="rating">Rating</option> <option value="rating">Rating</option>

View File

@@ -2,7 +2,7 @@ import React, { InputHTMLAttributes, ReactNode } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
error?: boolean; error?: boolean;
errorMessage?: string; errorMessage?: string | undefined;
} }
export default function Input({ export default function Input({

View File

@@ -70,7 +70,11 @@ export default function Modal({
if (focusable.length === 0) return; if (focusable.length === 0) return;
const first = focusable[0]; const first = focusable[0];
const last = focusable[focusable.length - 1]; const last = focusable[focusable.length - 1] ?? first;
if (!first || !last) {
return;
}
if (!event.shiftKey && document.activeElement === last) { if (!event.shiftKey && document.activeElement === last) {
event.preventDefault(); event.preventDefault();

View File

@@ -10,7 +10,7 @@ interface RangeFieldProps {
step?: number; step?: number;
onChange: (value: number) => void; onChange: (value: number) => void;
helperText?: string; helperText?: string;
error?: string; error?: string | undefined;
disabled?: boolean; disabled?: boolean;
unitLabel?: string; unitLabel?: string;
rangeHint?: string; rangeHint?: string;

View File

@@ -60,9 +60,13 @@ export class InMemoryAuthService implements AuthService {
const provider = new IracingDemoIdentityProviderAdapter(); const provider = new IracingDemoIdentityProviderAdapter();
const useCase = new StartAuthUseCase(provider); const useCase = new StartAuthUseCase(provider);
const command: StartAuthCommandDTO = { const command: StartAuthCommandDTO = returnTo
? {
provider: 'IRACING_DEMO', provider: 'IRACING_DEMO',
returnTo, returnTo,
}
: {
provider: 'IRACING_DEMO',
}; };
return useCase.execute(command); return useCase.execute(command);
@@ -77,11 +81,17 @@ export class InMemoryAuthService implements AuthService {
const sessionPort = new CookieIdentitySessionAdapter(); const sessionPort = new CookieIdentitySessionAdapter();
const useCase = new HandleAuthCallbackUseCase(provider, sessionPort); const useCase = new HandleAuthCallbackUseCase(provider, sessionPort);
const command: AuthCallbackCommandDTO = { const command: AuthCallbackCommandDTO = params.returnTo
? {
provider: 'IRACING_DEMO', provider: 'IRACING_DEMO',
code: params.code, code: params.code,
state: params.state, state: params.state,
returnTo: params.returnTo, returnTo: params.returnTo,
}
: {
provider: 'IRACING_DEMO',
code: params.code,
state: params.state,
}; };
return useCase.execute(command); return useCase.execute(command);

View File

@@ -24,12 +24,23 @@ export function useEffectiveDriverId(): string {
try { try {
// Lazy-load to avoid importing DI facade at module evaluation time // Lazy-load to avoid importing DI facade at module evaluation time
const { getDriverRepository } = require('./di-container') as typeof import('./di-container'); const { getDriverRepository } =
require('./di-container') as typeof import('./di-container');
const repo = getDriverRepository(); const repo = getDriverRepository();
// In-memory repository is synchronous for findAll in the demo implementation
const allDrivers = repo.findAllSync?.() as Array<{ id: string }> | undefined; interface DriverRepositoryWithSyncFindAll {
if (allDrivers && allDrivers.length > 0) { findAllSync?: () => Array<{ id: string }>;
return allDrivers[0].id; }
// In alpha/demo mode the in-memory repository exposes a synchronous finder;
// access it via a safe dynamic lookup to keep typing compatible with the port.
const repoWithSync = repo as DriverRepositoryWithSyncFindAll;
const allDrivers = repoWithSync.findAllSync?.();
if (Array.isArray(allDrivers) && allDrivers.length > 0) {
const firstDriver = allDrivers[0];
if (firstDriver) {
return firstDriver.id;
}
} }
} catch { } catch {
// Ignore and fall back to legacy default below // Ignore and fall back to legacy default below

View File

@@ -10,7 +10,7 @@ import { Season } from '@gridpilot/racing/domain/entities/Season';
import { Sponsor } from '@gridpilot/racing/domain/entities/Sponsor'; import { Sponsor } from '@gridpilot/racing/domain/entities/Sponsor';
import { SeasonSponsorship } from '@gridpilot/racing/domain/entities/SeasonSponsorship'; import { SeasonSponsorship } from '@gridpilot/racing/domain/entities/SeasonSponsorship';
import { Money } from '@gridpilot/racing/domain/value-objects/Money'; import { Money } from '@gridpilot/racing/domain/value-objects/Money';
import type { LeagueMembership, JoinRequest } from '@gridpilot/racing/domain/entities/LeagueMembership'; import type { JoinRequest } from '@gridpilot/racing/domain/entities/LeagueMembership';
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository'; import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
@@ -139,7 +139,6 @@ import { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/us
import { CancelRaceUseCase } from '@gridpilot/racing/application/use-cases/CancelRaceUseCase'; import { CancelRaceUseCase } from '@gridpilot/racing/application/use-cases/CancelRaceUseCase';
import { ImportRaceResultsUseCase } from '@gridpilot/racing/application/use-cases/ImportRaceResultsUseCase'; import { ImportRaceResultsUseCase } from '@gridpilot/racing/application/use-cases/ImportRaceResultsUseCase';
import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter'; import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter';
import { TeamsLeaderboardPresenter } from './presenters/TeamsLeaderboardPresenter';
import { RacesPagePresenter } from './presenters/RacesPagePresenter'; import { RacesPagePresenter } from './presenters/RacesPagePresenter';
import { AllRacesPagePresenter } from './presenters/AllRacesPagePresenter'; import { AllRacesPagePresenter } from './presenters/AllRacesPagePresenter';
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter'; import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
@@ -195,7 +194,24 @@ export function configureDIContainer(): void {
const primaryDriverId = seedData.drivers[0]!.id; const primaryDriverId = seedData.drivers[0]!.id;
// Create driver statistics from seed data // Create driver statistics from seed data
const driverStats = createDemoDriverStats(seedData.drivers); type DemoDriverStatsEntry = {
rating?: number;
wins?: number;
podiums?: number;
dnfs?: number;
totalRaces?: number;
avgFinish?: number;
bestFinish?: number;
worstFinish?: number;
overallRank?: number;
consistency?: number;
percentile?: number;
driverId?: string;
};
type DemoDriverStatsMap = Record<string, DemoDriverStatsEntry>;
const driverStats: DemoDriverStatsMap = createDemoDriverStats(seedData.drivers);
// Register repositories // Register repositories
container.registerInstance<IDriverRepository>( container.registerInstance<IDriverRepository>(
@@ -228,7 +244,11 @@ export function configureDIContainer(): void {
); );
// Race registrations - seed from results for completed races, plus some upcoming races // Race registrations - seed from results for completed races, plus some upcoming races
const seedRaceRegistrations: Array<{ raceId: string; driverId: string; registeredAt: Date }> = []; const seedRaceRegistrations: Array<{
raceId: string;
driverId: string;
registeredAt: Date;
}> = [];
// For completed races, extract driver registrations from results // For completed races, extract driver registrations from results
for (const result of seedData.results) { for (const result of seedData.results) {
@@ -280,6 +300,8 @@ export function configureDIContainer(): void {
const seededPenalties: Penalty[] = []; const seededPenalties: Penalty[] = [];
const seededProtests: Protest[] = []; const seededProtests: Protest[] = [];
type ProtestProps = Parameters<(typeof Protest)['create']>[0];
racesForProtests.forEach(({ race, leagueIndex: leagueIdx }, raceIndex) => { racesForProtests.forEach(({ race, leagueIndex: leagueIdx }, raceIndex) => {
const raceResults = seedData.results.filter(r => r.raceId === race.id); const raceResults = seedData.results.filter(r => r.raceId === race.id);
if (raceResults.length < 4) return; if (raceResults.length < 4) return;
@@ -291,33 +313,51 @@ export function configureDIContainer(): void {
if (!protestingResult || !accusedResult) continue; if (!protestingResult || !accusedResult) continue;
const protestStatuses: Array<'pending' | 'under_review' | 'upheld' | 'dismissed'> = ['pending', 'under_review', 'upheld', 'dismissed']; const protestStatuses = [
const status = protestStatuses[(raceIndex + i) % protestStatuses.length]; 'pending',
'under_review',
'upheld',
'dismissed',
] as const;
const status =
protestStatuses[(raceIndex + i) % protestStatuses.length] ?? 'pending';
const protest = Protest.create({ const protestProps: ProtestProps = {
id: `protest-${race.id}-${i}`, id: `protest-${race.id}-${i}`,
raceId: race.id, raceId: race.id,
protestingDriverId: protestingResult.driverId, protestingDriverId: protestingResult.driverId,
accusedDriverId: accusedResult.driverId, accusedDriverId: accusedResult.driverId,
incident: { incident: {
lap: 5 + i * 3, lap: 5 + i * 3,
description: i === 0 description:
i === 0
? 'Unsafe rejoining to the track after going off, causing contact' ? 'Unsafe rejoining to the track after going off, causing contact'
: 'Aggressive defending, pushing competitor off track', : 'Aggressive defending, pushing competitor off track',
}, },
comment: i === 0 comment:
i === 0
? 'Driver rejoined directly into my racing line, causing contact and damaging my front wing.' ? 'Driver rejoined directly into my racing line, causing contact and damaging my front wing.'
: 'Driver moved under braking multiple times, forcing me off the circuit.', : 'Driver moved under braking multiple times, forcing me off the circuit.',
status, status,
filedAt: new Date(Date.now() - (raceIndex + 1) * 24 * 60 * 60 * 1000), filedAt: new Date(Date.now() - (raceIndex + 1) * 24 * 60 * 60 * 1000),
reviewedBy: status !== 'pending' ? primaryDriverId : undefined, };
decisionNotes: status === 'upheld'
? 'After reviewing the evidence, the accused driver is found at fault. Penalty applied.' if (status !== 'pending') {
: status === 'dismissed' protestProps.reviewedBy = primaryDriverId;
? 'No clear fault found. Racing incident.' protestProps.reviewedAt = new Date(
: undefined, Date.now() - raceIndex * 24 * 60 * 60 * 1000,
reviewedAt: status !== 'pending' ? new Date(Date.now() - raceIndex * 24 * 60 * 60 * 1000) : undefined, );
}); }
if (status === 'upheld') {
protestProps.decisionNotes =
'After reviewing the evidence, the accused driver is found at fault. Penalty applied.';
} else if (status === 'dismissed') {
protestProps.decisionNotes =
'No clear fault found. Racing incident.';
}
const protest = Protest.create(protestProps);
seededProtests.push(protest); seededProtests.push(protest);
@@ -448,7 +488,15 @@ export function configureDIContainer(): void {
); );
// League memberships // League memberships
const seededMemberships: LeagueMembership[] = seedData.memberships.map((m) => ({ type SeedMembership = {
leagueId: string;
driverId: string;
role: 'member' | 'owner' | 'admin' | 'steward';
status: 'active';
joinedAt: Date;
};
const seededMemberships: SeedMembership[] = seedData.memberships.map((m) => ({
leagueId: m.leagueId, leagueId: m.leagueId,
driverId: m.driverId, driverId: m.driverId,
role: 'member', role: 'member',
@@ -476,11 +524,11 @@ export function configureDIContainer(): void {
// Ensure primary driver owns at least one league // Ensure primary driver owns at least one league
const hasPrimaryOwnerMembership = seededMemberships.some( const hasPrimaryOwnerMembership = seededMemberships.some(
(m: LeagueMembership) => m.driverId === primaryDriverId && m.role === 'owner', (m) => m.driverId === primaryDriverId && m.role === 'owner',
); );
if (!hasPrimaryOwnerMembership && seedData.leagues.length > 0) { if (!hasPrimaryOwnerMembership && seedData.leagues.length > 0) {
const targetLeague = const targetLeague =
seedData.leagues.find((l) => l.ownerId === primaryDriverId) ?? seedData.leagues[0]; seedData.leagues.find((l) => l.ownerId === primaryDriverId) ?? seedData.leagues[0]!;
const existingForPrimary = seededMemberships.find( const existingForPrimary = seededMemberships.find(
(m) => m.leagueId === targetLeague.id && m.driverId === primaryDriverId, (m) => m.leagueId === targetLeague.id && m.driverId === primaryDriverId,
@@ -574,23 +622,36 @@ export function configureDIContainer(): void {
'Heard great things about this league. Can I join?', 'Heard great things about this league. Can I join?',
'Experienced driver looking for competitive racing.', 'Experienced driver looking for competitive racing.',
'My friend recommended this league. Hope to race with you!', 'My friend recommended this league. Hope to race with you!',
]; ] as const;
const message =
messages[(index + leagueIndex) % messages.length] ?? messages[0];
seededJoinRequests.push({ seededJoinRequests.push({
id: `join-${league.id}-${driver.id}`, id: `join-${league.id}-${driver.id}`,
leagueId: league.id, leagueId: league.id,
driverId: driver.id, driverId: driver.id,
requestedAt: new Date(Date.now() - (index + 1 + leagueIndex) * 24 * 60 * 60 * 1000), requestedAt: new Date(
message: messages[(index + leagueIndex) % messages.length], Date.now() - (index + 1 + leagueIndex) * 24 * 60 * 60 * 1000,
),
message,
}); });
}); });
}); });
type InMemoryLeagueMembershipSeed = ConstructorParameters<
typeof InMemoryLeagueMembershipRepository
>[0];
container.registerInstance<ILeagueMembershipRepository>( container.registerInstance<ILeagueMembershipRepository>(
DI_TOKENS.LeagueMembershipRepository, DI_TOKENS.LeagueMembershipRepository,
new InMemoryLeagueMembershipRepository(seededMemberships, seededJoinRequests) new InMemoryLeagueMembershipRepository(
seededMemberships as InMemoryLeagueMembershipSeed,
seededJoinRequests,
)
); );
// Team repositories // Team repositories
type InMemoryTeamSeed = ConstructorParameters<typeof InMemoryTeamRepository>[0];
container.registerInstance<ITeamRepository>( container.registerInstance<ITeamRepository>(
DI_TOKENS.TeamRepository, DI_TOKENS.TeamRepository,
new InMemoryTeamRepository( new InMemoryTeamRepository(
@@ -602,8 +663,8 @@ export function configureDIContainer(): void {
ownerId: seedData.drivers[0]!.id, ownerId: seedData.drivers[0]!.id,
leagues: [t.primaryLeagueId], leagues: [t.primaryLeagueId],
createdAt: new Date(), createdAt: new Date(),
})) })) as InMemoryTeamSeed,
) ),
); );
container.registerInstance<ITeamMembershipRepository>( container.registerInstance<ITeamMembershipRepository>(
@@ -644,16 +705,13 @@ export function configureDIContainer(): void {
); );
const sponsorRepo = new InMemorySponsorRepository(); const sponsorRepo = new InMemorySponsorRepository();
// Use synchronous seeding via internal method sponsorRepo.seed(seededSponsors);
seededSponsors.forEach(sponsor => {
(sponsorRepo as any).sponsors.set(sponsor.id, sponsor);
});
container.registerInstance<ISponsorRepository>( container.registerInstance<ISponsorRepository>(
DI_TOKENS.SponsorRepository, DI_TOKENS.SponsorRepository,
sponsorRepo sponsorRepo
); );
const seededSponsorships = seedData.seasonSponsorships.map(ss => const seededSponsorships = seedData.seasonSponsorships.map((ss) =>
SeasonSponsorship.create({ SeasonSponsorship.create({
id: ss.id, id: ss.id,
seasonId: ss.seasonId, seasonId: ss.seasonId,
@@ -661,15 +719,12 @@ export function configureDIContainer(): void {
tier: ss.tier, tier: ss.tier,
pricing: Money.create(ss.pricingAmount, ss.pricingCurrency), pricing: Money.create(ss.pricingAmount, ss.pricingCurrency),
status: ss.status, status: ss.status,
description: ss.description, description: ss.description ?? '',
}) }),
); );
const seasonSponsorshipRepo = new InMemorySeasonSponsorshipRepository(); const seasonSponsorshipRepo = new InMemorySeasonSponsorshipRepository();
// Use synchronous seeding via internal method seasonSponsorshipRepo.seed(seededSponsorships);
seededSponsorships.forEach(sponsorship => {
(seasonSponsorshipRepo as any).sponsorships.set(sponsorship.id, sponsorship);
});
container.registerInstance<ISeasonSponsorshipRepository>( container.registerInstance<ISeasonSponsorshipRepository>(
DI_TOKENS.SeasonSponsorshipRepository, DI_TOKENS.SeasonSponsorshipRepository,
seasonSponsorshipRepo seasonSponsorshipRepo
@@ -691,9 +746,9 @@ export function configureDIContainer(): void {
); );
// Seed sponsorship requests from demo data // Seed sponsorship requests from demo data
seedData.sponsorshipRequests?.forEach(request => { if (seedData.sponsorshipRequests && seedData.sponsorshipRequests.length > 0) {
(sponsorshipRequestRepo as any).requests.set(request.id, request); sponsorshipRequestRepo.seed(seedData.sponsorshipRequests);
}); }
// Social repositories // Social repositories
container.registerInstance<IFeedRepository>( container.registerInstance<IFeedRepository>(
@@ -732,7 +787,7 @@ export function configureDIContainer(): void {
); );
// Register driver stats for access by utility functions // Register driver stats for access by utility functions
container.registerInstance( container.registerInstance<DemoDriverStatsMap>(
DI_TOKENS.DriverStats, DI_TOKENS.DriverStats,
driverStats driverStats
); );
@@ -741,7 +796,8 @@ export function configureDIContainer(): void {
const driverRatingProvider: DriverRatingProvider = { const driverRatingProvider: DriverRatingProvider = {
getRating: (driverId: string): number | null => { getRating: (driverId: string): number | null => {
const stats = driverStats[driverId]; const stats = driverStats[driverId];
return stats?.rating ?? null; const rating = stats?.rating;
return typeof rating === 'number' ? rating : null;
}, },
getRatings: (driverIds: string[]): Map<string, number> => { getRatings: (driverIds: string[]): Map<string, number> => {
const result = new Map<string, number>(); const result = new Map<string, number>();
@@ -905,7 +961,7 @@ export function configureDIContainer(): void {
const leagueStandingsPresenter = new LeagueStandingsPresenter(); const leagueStandingsPresenter = new LeagueStandingsPresenter();
container.registerInstance( container.registerInstance(
DI_TOKENS.GetLeagueStandingsUseCase, DI_TOKENS.GetLeagueStandingsUseCase,
new GetLeagueStandingsUseCase(standingRepository, leagueStandingsPresenter) new GetLeagueStandingsUseCase(standingRepository),
); );
const leagueDriverSeasonStatsPresenter = new LeagueDriverSeasonStatsPresenter(); const leagueDriverSeasonStatsPresenter = new LeagueDriverSeasonStatsPresenter();
@@ -919,7 +975,7 @@ export function configureDIContainer(): void {
{ {
getRating: (driverId: string) => { getRating: (driverId: string) => {
const stats = driverStats[driverId]; const stats = driverStats[driverId];
if (!stats) { if (!stats || typeof stats.rating !== 'number') {
return { rating: null, ratingChange: null }; return { rating: null, ratingChange: null };
} }
const baseline = 1500; const baseline = 1500;
@@ -930,8 +986,8 @@ export function configureDIContainer(): void {
}; };
}, },
}, },
leagueDriverSeasonStatsPresenter leagueDriverSeasonStatsPresenter,
) ),
); );
const allLeaguesWithCapacityPresenter = new AllLeaguesWithCapacityPresenter(); const allLeaguesWithCapacityPresenter = new AllLeaguesWithCapacityPresenter();
@@ -961,7 +1017,7 @@ export function configureDIContainer(): void {
const leagueScoringPresetsPresenter = new LeagueScoringPresetsPresenter(); const leagueScoringPresetsPresenter = new LeagueScoringPresetsPresenter();
container.registerInstance( container.registerInstance(
DI_TOKENS.ListLeagueScoringPresetsUseCase, DI_TOKENS.ListLeagueScoringPresetsUseCase,
new ListLeagueScoringPresetsUseCase(leagueScoringPresetProvider, leagueScoringPresetsPresenter) new ListLeagueScoringPresetsUseCase(leagueScoringPresetProvider)
); );
const leagueScoringConfigPresenter = new LeagueScoringConfigPresenter(); const leagueScoringConfigPresenter = new LeagueScoringConfigPresenter();
@@ -977,7 +1033,6 @@ export function configureDIContainer(): void {
) )
); );
const leagueFullConfigPresenter = new LeagueFullConfigPresenter();
container.registerInstance( container.registerInstance(
DI_TOKENS.GetLeagueFullConfigUseCase, DI_TOKENS.GetLeagueFullConfigUseCase,
new GetLeagueFullConfigUseCase( new GetLeagueFullConfigUseCase(
@@ -985,14 +1040,13 @@ export function configureDIContainer(): void {
seasonRepository, seasonRepository,
leagueScoringConfigRepository, leagueScoringConfigRepository,
gameRepository, gameRepository,
leagueFullConfigPresenter
) )
); );
const leagueSchedulePreviewPresenter = new LeagueSchedulePreviewPresenter(); const leagueSchedulePreviewPresenter = new LeagueSchedulePreviewPresenter();
container.registerInstance( container.registerInstance(
DI_TOKENS.PreviewLeagueScheduleUseCase, DI_TOKENS.PreviewLeagueScheduleUseCase,
new PreviewLeagueScheduleUseCase(undefined, leagueSchedulePreviewPresenter) new PreviewLeagueScheduleUseCase(undefined, leagueSchedulePreviewPresenter),
); );
const raceWithSOFPresenter = new RaceWithSOFPresenter(); const raceWithSOFPresenter = new RaceWithSOFPresenter();
@@ -1031,6 +1085,8 @@ export function configureDIContainer(): void {
new GetAllRacesPageDataUseCase(raceRepository, leagueRepository, allRacesPagePresenter) new GetAllRacesPageDataUseCase(raceRepository, leagueRepository, allRacesPagePresenter)
); );
const imageService = container.resolve<ImageServicePort>(DI_TOKENS.ImageService);
const raceDetailPresenter = new RaceDetailPresenter(); const raceDetailPresenter = new RaceDetailPresenter();
container.registerInstance( container.registerInstance(
DI_TOKENS.GetRaceDetailUseCase, DI_TOKENS.GetRaceDetailUseCase,
@@ -1075,38 +1131,38 @@ export function configureDIContainer(): void {
// Create services for driver leaderboard query // Create services for driver leaderboard query
const rankingService = { const rankingService = {
getAllDriverRankings: () => { getAllDriverRankings: () => {
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats); const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
return Object.entries(stats).map(([driverId, stat]) => ({ return Object.entries(stats)
.map(([driverId, stat]) => ({
driverId, driverId,
rating: stat.rating, rating: stat.rating ?? 0,
overallRank: stat.overallRank, overallRank: stat.overallRank ?? null,
})).sort((a, b) => b.rating - a.rating); }))
} .sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0));
},
}; };
const driverStatsService = { const driverStatsService = {
getDriverStats: (driverId: string) => { getDriverStats: (driverId: string) => {
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats); const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
return stats[driverId] || null; return stats[driverId] ?? null;
} },
}; };
const imageService = getDIContainer().resolve<ImageServicePort>(DI_TOKENS.ImageService);
const driversPresenter = new DriversLeaderboardPresenter(); const driversPresenter = new DriversLeaderboardPresenter();
container.registerInstance( container.registerInstance(
DI_TOKENS.GetDriversLeaderboardUseCase, DI_TOKENS.GetDriversLeaderboardUseCase,
new GetDriversLeaderboardUseCase( new GetDriversLeaderboardUseCase(
driverRepository, driverRepository,
rankingService as any, rankingService,
driverStatsService as any, driverStatsService,
imageService, imageService,
driversPresenter driversPresenter
) )
); );
const getDriverStatsAdapter = (driverId: string) => { const getDriverStatsAdapter = (driverId: string) => {
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats); const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
const stat = stats[driverId]; const stat = stats[driverId];
if (!stat) return null; if (!stat) return null;
return { return {
@@ -1116,7 +1172,6 @@ export function configureDIContainer(): void {
}; };
}; };
const teamsPresenter = new TeamsLeaderboardPresenter();
container.registerInstance( container.registerInstance(
DI_TOKENS.GetTeamsLeaderboardUseCase, DI_TOKENS.GetTeamsLeaderboardUseCase,
new GetTeamsLeaderboardUseCase( new GetTeamsLeaderboardUseCase(
@@ -1124,12 +1179,11 @@ export function configureDIContainer(): void {
teamMembershipRepository, teamMembershipRepository,
driverRepository, driverRepository,
getDriverStatsAdapter, getDriverStatsAdapter,
teamsPresenter
) )
); );
const getDriverStatsForDashboard = (driverId: string) => { const getDriverStatsForDashboard = (driverId: string) => {
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats); const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
const stat = stats[driverId]; const stat = stats[driverId];
if (!stat) return null; if (!stat) return null;
return { return {
@@ -1143,7 +1197,7 @@ export function configureDIContainer(): void {
}; };
const getDriverStatsForProfile = (driverId: string) => { const getDriverStatsForProfile = (driverId: string) => {
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats); const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
const stat = stats[driverId]; const stat = stats[driverId];
if (!stat) return null; if (!stat) return null;
return { return {
@@ -1204,7 +1258,7 @@ export function configureDIContainer(): void {
const allTeamsPresenter = new AllTeamsPresenter(); const allTeamsPresenter = new AllTeamsPresenter();
container.registerInstance( container.registerInstance(
DI_TOKENS.GetAllTeamsUseCase, DI_TOKENS.GetAllTeamsUseCase,
new GetAllTeamsUseCase(teamRepository, teamMembershipRepository, allTeamsPresenter) new GetAllTeamsUseCase(teamRepository, teamMembershipRepository),
); );
const teamDetailsPresenter = new TeamDetailsPresenter(); const teamDetailsPresenter = new TeamDetailsPresenter();
@@ -1216,13 +1270,18 @@ export function configureDIContainer(): void {
const teamMembersPresenter = new TeamMembersPresenter(); const teamMembersPresenter = new TeamMembersPresenter();
container.registerInstance( container.registerInstance(
DI_TOKENS.GetTeamMembersUseCase, DI_TOKENS.GetTeamMembersUseCase,
new GetTeamMembersUseCase(teamMembershipRepository, driverRepository, imageService, teamMembersPresenter) new GetTeamMembersUseCase(teamMembershipRepository, driverRepository, imageService, teamMembersPresenter),
); );
const teamJoinRequestsPresenter = new TeamJoinRequestsPresenter(); const teamJoinRequestsPresenter = new TeamJoinRequestsPresenter();
container.registerInstance( container.registerInstance(
DI_TOKENS.GetTeamJoinRequestsUseCase, DI_TOKENS.GetTeamJoinRequestsUseCase,
new GetTeamJoinRequestsUseCase(teamMembershipRepository, driverRepository, imageService, teamJoinRequestsPresenter) new GetTeamJoinRequestsUseCase(
teamMembershipRepository,
driverRepository,
imageService,
teamJoinRequestsPresenter,
),
); );
const driverTeamPresenter = new DriverTeamPresenter(); const driverTeamPresenter = new DriverTeamPresenter();
@@ -1235,13 +1294,13 @@ export function configureDIContainer(): void {
const raceProtestsPresenter = new RaceProtestsPresenter(); const raceProtestsPresenter = new RaceProtestsPresenter();
container.registerInstance( container.registerInstance(
DI_TOKENS.GetRaceProtestsUseCase, DI_TOKENS.GetRaceProtestsUseCase,
new GetRaceProtestsUseCase(protestRepository, driverRepository, raceProtestsPresenter) new GetRaceProtestsUseCase(protestRepository, driverRepository)
); );
const racePenaltiesPresenter = new RacePenaltiesPresenter(); const racePenaltiesPresenter = new RacePenaltiesPresenter();
container.registerInstance( container.registerInstance(
DI_TOKENS.GetRacePenaltiesUseCase, DI_TOKENS.GetRacePenaltiesUseCase,
new GetRacePenaltiesUseCase(penaltyRepository, driverRepository, racePenaltiesPresenter) new GetRacePenaltiesUseCase(penaltyRepository, driverRepository)
); );
// Register queries - Notifications // Register queries - Notifications
@@ -1286,13 +1345,11 @@ export function configureDIContainer(): void {
const sponsorshipRequestRepository = container.resolve<ISponsorshipRequestRepository>(DI_TOKENS.SponsorshipRequestRepository); const sponsorshipRequestRepository = container.resolve<ISponsorshipRequestRepository>(DI_TOKENS.SponsorshipRequestRepository);
const sponsorshipPricingRepository = container.resolve<ISponsorshipPricingRepository>(DI_TOKENS.SponsorshipPricingRepository); const sponsorshipPricingRepository = container.resolve<ISponsorshipPricingRepository>(DI_TOKENS.SponsorshipPricingRepository);
const pendingSponsorshipRequestsPresenter = new PendingSponsorshipRequestsPresenter();
container.registerInstance( container.registerInstance(
DI_TOKENS.GetPendingSponsorshipRequestsUseCase, DI_TOKENS.GetPendingSponsorshipRequestsUseCase,
new GetPendingSponsorshipRequestsUseCase( new GetPendingSponsorshipRequestsUseCase(
sponsorshipRequestRepository, sponsorshipRequestRepository,
sponsorRepository, sponsorRepository,
pendingSponsorshipRequestsPresenter
) )
); );

View File

@@ -6,6 +6,7 @@
import { configureDIContainer, getDIContainer } from './di-config'; import { configureDIContainer, getDIContainer } from './di-config';
import { DI_TOKENS } from './di-tokens'; import { DI_TOKENS } from './di-tokens';
import { LeagueScoringPresetsPresenter } from './presenters/LeagueScoringPresetsPresenter';
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository'; import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
@@ -97,6 +98,7 @@ import type { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/applicati
import type { DriverRatingProvider } from '@gridpilot/racing/application'; import type { DriverRatingProvider } from '@gridpilot/racing/application';
import type { PreviewLeagueScheduleUseCase } from '@gridpilot/racing/application'; import type { PreviewLeagueScheduleUseCase } from '@gridpilot/racing/application';
import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { GetDashboardOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetDashboardOverviewUseCase'; import type { GetDashboardOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetDashboardOverviewUseCase';
import { createDemoDriverStats, getDemoLeagueRankings, type DriverStats } from '@gridpilot/testing-support'; import { createDemoDriverStats, getDemoLeagueRankings, type DriverStats } from '@gridpilot/testing-support';
@@ -613,6 +615,21 @@ export function getIsDriverRegisteredForRaceUseCase(): IsDriverRegisteredForRace
return DIContainer.getInstance().isDriverRegisteredForRaceUseCase; return DIContainer.getInstance().isDriverRegisteredForRaceUseCase;
} }
/**
* Query facade for checking if a driver is registered for a race.
*/
export function getIsDriverRegisteredForRaceQuery(): {
execute(input: { raceId: string; driverId: string }): Promise<boolean>;
} {
const useCase = DIContainer.getInstance().isDriverRegisteredForRaceUseCase;
return {
async execute(input: { raceId: string; driverId: string }): Promise<boolean> {
const result = await useCase.execute(input);
return result as unknown as boolean;
},
};
}
export function getGetRaceRegistrationsUseCase(): GetRaceRegistrationsUseCase { export function getGetRaceRegistrationsUseCase(): GetRaceRegistrationsUseCase {
return DIContainer.getInstance().getRaceRegistrationsUseCase; return DIContainer.getInstance().getRaceRegistrationsUseCase;
} }
@@ -649,6 +666,24 @@ export function getListLeagueScoringPresetsUseCase(): ListLeagueScoringPresetsUs
return DIContainer.getInstance().listLeagueScoringPresetsUseCase; return DIContainer.getInstance().listLeagueScoringPresetsUseCase;
} }
/**
* Lightweight query facade for listing league scoring presets.
* Returns an object with an execute() method for use in UI code.
*/
export function getListLeagueScoringPresetsQuery(): {
execute(): Promise<LeagueScoringPresetDTO[]>;
} {
const useCase = DIContainer.getInstance().listLeagueScoringPresetsUseCase;
return {
async execute(): Promise<LeagueScoringPresetDTO[]> {
const presenter = new LeagueScoringPresetsPresenter();
await useCase.execute(undefined as void, presenter);
const viewModel = presenter.getViewModel();
return viewModel.presets;
},
};
}
export function getCreateLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase { export function getCreateLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase {
return DIContainer.getInstance().createLeagueWithSeasonAndScoringUseCase; return DIContainer.getInstance().createLeagueWithSeasonAndScoringUseCase;
} }

View File

@@ -40,6 +40,7 @@ const leagueMemberships = new Map<string, LeagueMembership[]>();
const memberships = await membershipRepo.getLeagueMembers(league.id); const memberships = await membershipRepo.getLeagueMembers(league.id);
const mapped: LeagueMembership[] = memberships.map((membership) => ({ const mapped: LeagueMembership[] = memberships.map((membership) => ({
id: membership.id,
leagueId: membership.leagueId, leagueId: membership.leagueId,
driverId: membership.driverId, driverId: membership.driverId,
role: membership.role, role: membership.role,

View File

@@ -53,13 +53,13 @@ export function validateLeagueWizardStep(
// Use LeagueName value object for validation // Use LeagueName value object for validation
const nameValidation = LeagueName.validate(form.basics.name); const nameValidation = LeagueName.validate(form.basics.name);
if (!nameValidation.valid) { if (!nameValidation.valid && nameValidation.error) {
basicsErrors.name = nameValidation.error; basicsErrors.name = nameValidation.error;
} }
// Use LeagueDescription value object for validation // Use LeagueDescription value object for validation
const descValidation = LeagueDescription.validate(form.basics.description ?? ''); const descValidation = LeagueDescription.validate(form.basics.description ?? '');
if (!descValidation.valid) { if (!descValidation.valid && descValidation.error) {
basicsErrors.description = descValidation.error; basicsErrors.description = descValidation.error;
} }
@@ -92,8 +92,10 @@ export function validateLeagueWizardStep(
'Max drivers must be greater than 0 for solo leagues'; 'Max drivers must be greater than 0 for solo leagues';
} else { } else {
// Validate against game constraints // Validate against game constraints
const driverValidation = gameConstraints.validateDriverCount(form.structure.maxDrivers); const driverValidation = gameConstraints.validateDriverCount(
if (!driverValidation.valid) { form.structure.maxDrivers,
);
if (!driverValidation.valid && driverValidation.error) {
structureErrors.maxDrivers = driverValidation.error; structureErrors.maxDrivers = driverValidation.error;
} }
} }
@@ -103,8 +105,10 @@ export function validateLeagueWizardStep(
'Max teams must be greater than 0 for team leagues'; 'Max teams must be greater than 0 for team leagues';
} else { } else {
// Validate against game constraints // Validate against game constraints
const teamValidation = gameConstraints.validateTeamCount(form.structure.maxTeams); const teamValidation = gameConstraints.validateTeamCount(
if (!teamValidation.valid) { form.structure.maxTeams,
);
if (!teamValidation.valid && teamValidation.error) {
structureErrors.maxTeams = teamValidation.error; structureErrors.maxTeams = teamValidation.error;
} }
} }
@@ -114,8 +118,10 @@ export function validateLeagueWizardStep(
} }
// Validate total driver count // Validate total driver count
if (form.structure.maxDrivers) { if (form.structure.maxDrivers) {
const driverValidation = gameConstraints.validateDriverCount(form.structure.maxDrivers); const driverValidation = gameConstraints.validateDriverCount(
if (!driverValidation.valid) { form.structure.maxDrivers,
);
if (!driverValidation.valid && driverValidation.error) {
structureErrors.maxDrivers = driverValidation.error; structureErrors.maxDrivers = driverValidation.error;
} }
} }
@@ -197,7 +203,7 @@ export function validateAllLeagueWizardSteps(
export function hasWizardErrors(errors: WizardErrors): boolean { export function hasWizardErrors(errors: WizardErrors): boolean {
return Object.keys(errors).some((key) => { return Object.keys(errors).some((key) => {
const value = (errors as any)[key]; const value = errors[key as keyof WizardErrors];
if (!value) return false; if (!value) return false;
if (typeof value === 'string') return true; if (typeof value === 'string') return true;
return Object.keys(value).length > 0; return Object.keys(value).length > 0;
@@ -213,27 +219,31 @@ export function buildCreateLeagueCommandFromConfig(
ownerId: string, ownerId: string,
): CreateLeagueWithSeasonAndScoringCommand { ): CreateLeagueWithSeasonAndScoringCommand {
const structure = form.structure; const structure = form.structure;
let maxDrivers: number | undefined; let maxDrivers: number;
let maxTeams: number | undefined; let maxTeams: number;
if (structure.mode === 'solo') { if (structure.mode === 'solo') {
maxDrivers = maxDrivers =
typeof structure.maxDrivers === 'number' ? structure.maxDrivers : undefined; typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
maxTeams = undefined; ? structure.maxDrivers
: 0;
maxTeams = 0;
} else { } else {
const teams = const teams =
typeof structure.maxTeams === 'number' ? structure.maxTeams : 0; typeof structure.maxTeams === 'number' && structure.maxTeams > 0
? structure.maxTeams
: 0;
const perTeam = const perTeam =
typeof structure.driversPerTeam === 'number' typeof structure.driversPerTeam === 'number' && structure.driversPerTeam > 0
? structure.driversPerTeam ? structure.driversPerTeam
: 0; : 0;
maxTeams = teams > 0 ? teams : undefined; maxTeams = teams;
maxDrivers = teams > 0 && perTeam > 0 ? teams * perTeam : undefined; maxDrivers = teams > 0 && perTeam > 0 ? teams * perTeam : 0;
} }
return { return {
name: form.basics.name.trim(), name: form.basics.name.trim(),
description: form.basics.description?.trim() || undefined, description: (form.basics.description ?? '').trim(),
visibility: form.basics.visibility, visibility: form.basics.visibility,
ownerId, ownerId,
gameId: form.basics.gameId, gameId: form.basics.gameId,
@@ -243,7 +253,7 @@ export function buildCreateLeagueCommandFromConfig(
enableTeamChampionship: form.championships.enableTeamChampionship, enableTeamChampionship: form.championships.enableTeamChampionship,
enableNationsChampionship: form.championships.enableNationsChampionship, enableNationsChampionship: form.championships.enableNationsChampionship,
enableTrophyChampionship: form.championships.enableTrophyChampionship, enableTrophyChampionship: form.championships.enableTrophyChampionship,
scoringPresetId: form.scoring.patternId || undefined, scoringPresetId: form.scoring.patternId ?? 'custom',
}; };
} }
@@ -263,8 +273,8 @@ export async function createLeagueFromConfig(
if (!currentDriver) { if (!currentDriver) {
const error = new Error( const error = new Error(
'No driver profile found. Please create a driver profile first.', 'No driver profile found. Please create a driver profile first.',
); ) as Error & { code?: string };
(error as any).code = 'NO_DRIVER'; error.code = 'NO_DRIVER';
throw error; throw error;
} }
@@ -283,7 +293,9 @@ export function applyScoringPresetToConfig(
): LeagueConfigFormModel { ): LeagueConfigFormModel {
const lowerPresetId = patternId.toLowerCase(); const lowerPresetId = patternId.toLowerCase();
const timings = form.timings ?? ({} as LeagueConfigFormModel['timings']); const timings = form.timings ?? ({} as LeagueConfigFormModel['timings']);
let updatedTimings = { ...timings }; let updatedTimings: NonNullable<LeagueConfigFormModel['timings']> = {
...timings,
};
if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) { if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) {
updatedTimings = { updatedTimings = {
@@ -299,19 +311,19 @@ export function applyScoringPresetToConfig(
...updatedTimings, ...updatedTimings,
practiceMinutes: 30, practiceMinutes: 30,
qualifyingMinutes: 30, qualifyingMinutes: 30,
sprintRaceMinutes: undefined,
mainRaceMinutes: 90, mainRaceMinutes: 90,
sessionCount: 1, sessionCount: 1,
}; };
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
} else { } else {
updatedTimings = { updatedTimings = {
...updatedTimings, ...updatedTimings,
practiceMinutes: 20, practiceMinutes: 20,
qualifyingMinutes: 30, qualifyingMinutes: 30,
sprintRaceMinutes: undefined,
mainRaceMinutes: 40, mainRaceMinutes: 40,
sessionCount: 1, sessionCount: 1,
}; };
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
} }
return { return {

View File

@@ -25,8 +25,8 @@ export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWit
: 40; : 40;
const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`; const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`;
let scoringSummary: LeagueSummaryViewModel['scoring'] | undefined; let scoringPatternSummary: string | null = null;
let scoringPatternSummary: string | undefined; let scoringSummary: LeagueSummaryViewModel['scoring'];
if (season && scoringConfig && game) { if (season && scoringConfig && game) {
const dropPolicySummary = const dropPolicySummary =
@@ -47,9 +47,23 @@ export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWit
dropPolicySummary, dropPolicySummary,
scoringPatternSummary, scoringPatternSummary,
}; };
} else {
const dropPolicySummary = 'All results count';
const scoringPresetName = 'Custom';
scoringPatternSummary = scoringPatternSummary ?? `${scoringPresetName}${dropPolicySummary}`;
scoringSummary = {
gameId: 'unknown',
gameName: 'Unknown',
primaryChampionshipType: 'driver',
scoringPresetId: 'custom',
scoringPresetName,
dropPolicySummary,
scoringPatternSummary,
};
} }
return { const base: LeagueSummaryViewModel = {
id: league.id, id: league.id,
name: league.name, name: league.name,
description: league.description, description: league.description,
@@ -57,13 +71,16 @@ export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWit
createdAt: league.createdAt, createdAt: league.createdAt,
maxDrivers: safeMaxDrivers, maxDrivers: safeMaxDrivers,
usedDriverSlots, usedDriverSlots,
maxTeams: undefined, // Team capacity is not yet modeled here; use zero for now to satisfy strict typing.
usedTeamSlots: undefined, maxTeams: 0,
usedTeamSlots: 0,
structureSummary, structureSummary,
scoringPatternSummary, scoringPatternSummary: scoringPatternSummary ?? '',
timingSummary, timingSummary,
scoring: scoringSummary, scoring: scoringSummary,
}; };
return base;
}); });
this.viewModel = { this.viewModel = {

View File

@@ -20,7 +20,7 @@ export class AllLeaguesWithCapacityPresenter implements IAllLeaguesWithCapacityP
const configuredMax = league.settings.maxDrivers ?? usedSlots; const configuredMax = league.settings.maxDrivers ?? usedSlots;
const safeMaxDrivers = Math.max(configuredMax, usedSlots); const safeMaxDrivers = Math.max(configuredMax, usedSlots);
return { const base: LeagueWithCapacityViewModel = {
id: league.id, id: league.id,
name: league.name, name: league.name,
description: league.description, description: league.description,
@@ -30,15 +30,33 @@ export class AllLeaguesWithCapacityPresenter implements IAllLeaguesWithCapacityP
maxDrivers: safeMaxDrivers, maxDrivers: safeMaxDrivers,
}, },
createdAt: league.createdAt.toISOString(), createdAt: league.createdAt.toISOString(),
socialLinks: league.socialLinks
? {
discordUrl: league.socialLinks.discordUrl,
youtubeUrl: league.socialLinks.youtubeUrl,
websiteUrl: league.socialLinks.websiteUrl,
}
: undefined,
usedSlots, usedSlots,
}; };
if (!league.socialLinks) {
return base;
}
const socialLinks: NonNullable<LeagueWithCapacityViewModel['socialLinks']> = {};
if (league.socialLinks.discordUrl) {
socialLinks.discordUrl = league.socialLinks.discordUrl;
}
if (league.socialLinks.youtubeUrl) {
socialLinks.youtubeUrl = league.socialLinks.youtubeUrl;
}
if (league.socialLinks.websiteUrl) {
socialLinks.websiteUrl = league.socialLinks.websiteUrl;
}
if (Object.keys(socialLinks).length === 0) {
return base;
}
return {
...base,
socialLinks,
};
}); });
this.viewModel = { this.viewModel = {

View File

@@ -1,38 +1,34 @@
import type { Team } from '@gridpilot/racing/domain/entities/Team';
import type { import type {
IAllTeamsPresenter, IAllTeamsPresenter,
TeamListItemViewModel, TeamListItemViewModel,
AllTeamsViewModel, AllTeamsViewModel,
AllTeamsResultDTO,
} from '@gridpilot/racing/application/presenters/IAllTeamsPresenter'; } from '@gridpilot/racing/application/presenters/IAllTeamsPresenter';
export class AllTeamsPresenter implements IAllTeamsPresenter { export class AllTeamsPresenter implements IAllTeamsPresenter {
private viewModel: AllTeamsViewModel | null = null; private viewModel: AllTeamsViewModel | null = null;
present(teams: Array<Team & { memberCount?: number }>): AllTeamsViewModel { reset(): void {
const teamItems: TeamListItemViewModel[] = teams.map((team) => ({ this.viewModel = null;
}
present(input: AllTeamsResultDTO): void {
const teamItems: TeamListItemViewModel[] = input.teams.map((team) => ({
id: team.id, id: team.id,
name: team.name, name: team.name,
tag: team.tag, tag: team.tag,
description: team.description, description: team.description,
memberCount: team.memberCount ?? 0, memberCount: team.memberCount ?? 0,
leagues: team.leagues, leagues: team.leagues,
specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined,
region: team.region,
languages: team.languages,
})); }));
this.viewModel = { this.viewModel = {
teams: teamItems, teams: teamItems,
totalCount: teamItems.length, totalCount: teamItems.length,
}; };
return this.viewModel;
} }
getViewModel(): AllTeamsViewModel { getViewModel(): AllTeamsViewModel | null {
if (!this.viewModel) {
throw new Error('Presenter has not been called yet');
}
return this.viewModel; return this.viewModel;
} }
} }

View File

@@ -1,17 +1,19 @@
import type { Team, TeamMembership } from '@gridpilot/racing/domain/entities/Team';
import type { import type {
IDriverTeamPresenter, IDriverTeamPresenter,
DriverTeamViewModel, DriverTeamViewModel,
DriverTeamResultDTO,
} from '@gridpilot/racing/application/presenters/IDriverTeamPresenter'; } from '@gridpilot/racing/application/presenters/IDriverTeamPresenter';
export class DriverTeamPresenter implements IDriverTeamPresenter { export class DriverTeamPresenter implements IDriverTeamPresenter {
private viewModel: DriverTeamViewModel | null = null; private viewModel: DriverTeamViewModel | null = null;
present( reset(): void {
team: Team, this.viewModel = null;
membership: TeamMembership, }
driverId: string
): DriverTeamViewModel { present(input: DriverTeamResultDTO): void {
const { team, membership, driverId } = input;
const isOwner = team.ownerId === driverId; const isOwner = team.ownerId === driverId;
const canManage = membership.role === 'owner' || membership.role === 'manager'; const canManage = membership.role === 'owner' || membership.role === 'manager';
@@ -23,26 +25,18 @@ export class DriverTeamPresenter implements IDriverTeamPresenter {
description: team.description, description: team.description,
ownerId: team.ownerId, ownerId: team.ownerId,
leagues: team.leagues, leagues: team.leagues,
specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined,
region: team.region,
languages: team.languages,
}, },
membership: { membership: {
role: membership.role, role: membership.role === 'driver' ? 'member' : membership.role,
joinedAt: membership.joinedAt.toISOString(), joinedAt: membership.joinedAt.toISOString(),
isActive: membership.isActive, isActive: membership.status === 'active',
}, },
isOwner, isOwner,
canManage, canManage,
}; };
return this.viewModel;
} }
getViewModel(): DriverTeamViewModel { getViewModel(): DriverTeamViewModel | null {
if (!this.viewModel) {
throw new Error('Presenter has not been called yet');
}
return this.viewModel; return this.viewModel;
} }
} }

View File

@@ -1,5 +1,5 @@
import type { IEntitySponsorshipPricingPresenter } from '@racing/application/presenters/IEntitySponsorshipPricingPresenter'; import type { IEntitySponsorshipPricingPresenter } from '@gridpilot/racing/application/presenters/IEntitySponsorshipPricingPresenter';
import type { GetEntitySponsorshipPricingResultDTO } from '@racing/application/use-cases/GetEntitySponsorshipPricingUseCase'; import type { GetEntitySponsorshipPricingResultDTO } from '@gridpilot/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
export class EntitySponsorshipPricingPresenter implements IEntitySponsorshipPricingPresenter { export class EntitySponsorshipPricingPresenter implements IEntitySponsorshipPricingPresenter {
private data: GetEntitySponsorshipPricingResultDTO | null = null; private data: GetEntitySponsorshipPricingResultDTO | null = null;

View File

@@ -3,6 +3,8 @@ import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
import type { Race } from '@gridpilot/racing/domain/entities/Race'; import type { Race } from '@gridpilot/racing/domain/entities/Race';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import type { LeagueConfigFormViewModel } from '@gridpilot/racing/application/presenters/ILeagueFullConfigPresenter';
import { LeagueFullConfigPresenter } from '@/lib/presenters/LeagueFullConfigPresenter';
import type { MembershipRole } from '@/lib/leagueMembership'; import type { MembershipRole } from '@/lib/leagueMembership';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import { import {
@@ -38,6 +40,14 @@ export interface LeagueOwnerSummaryViewModel {
rank: number | null; rank: number | null;
} }
export interface LeagueSummaryViewModel {
id: string;
ownerId: string;
settings: {
pointsSystem: string;
};
}
export interface LeagueAdminProtestsViewModel { export interface LeagueAdminProtestsViewModel {
protests: Protest[]; protests: Protest[];
racesById: ProtestRaceSummary; racesById: ProtestRaceSummary;
@@ -79,14 +89,23 @@ export async function loadLeagueJoinRequests(leagueId: string): Promise<LeagueJo
driversById[dto.id] = dto; driversById[dto.id] = dto;
} }
return requests.map((request) => ({ return requests.map((request) => {
const base: LeagueJoinRequestViewModel = {
id: request.id, id: request.id,
leagueId: request.leagueId, leagueId: request.leagueId,
driverId: request.driverId, driverId: request.driverId,
requestedAt: request.requestedAt, requestedAt: request.requestedAt,
message: request.message, };
driver: driversById[request.driverId],
})); const message = request.message;
const driver = driversById[request.driverId];
return {
...base,
...(typeof message === 'string' && message.length > 0 ? { message } : {}),
...(driver ? { driver } : {}),
};
});
} }
/** /**
@@ -104,6 +123,7 @@ export async function approveLeagueJoinRequest(
} }
await membershipRepo.saveMembership({ await membershipRepo.saveMembership({
id: request.id,
leagueId: request.leagueId, leagueId: request.leagueId,
driverId: request.driverId, driverId: request.driverId,
role: 'member', role: 'member',
@@ -203,12 +223,17 @@ export async function updateLeagueMemberRole(
/** /**
* Load owner summary (DTO + rating/rank) for a league. * Load owner summary (DTO + rating/rank) for a league.
*/ */
export async function loadLeagueOwnerSummary(league: League): Promise<LeagueOwnerSummaryViewModel | null> { export async function loadLeagueOwnerSummary(params: {
ownerId: string;
}): Promise<LeagueOwnerSummaryViewModel | null> {
const driverRepo = getDriverRepository(); const driverRepo = getDriverRepository();
const entity = await driverRepo.findById(league.ownerId); const entity = await driverRepo.findById(params.ownerId);
if (!entity) return null; if (!entity) return null;
const ownerDriver = EntityMappers.toDriverDTO(entity); const ownerDriver = EntityMappers.toDriverDTO(entity);
if (!ownerDriver) {
return null;
}
const stats = getDriverStats(ownerDriver.id); const stats = getDriverStats(ownerDriver.id);
const allRankings = getAllDriverRankings(); const allRankings = getAllDriverRankings();
@@ -243,10 +268,52 @@ export async function loadLeagueOwnerSummary(league: League): Promise<LeagueOwne
/** /**
* Load league full config form. * Load league full config form.
*/ */
export async function loadLeagueConfig(leagueId: string): Promise<LeagueAdminConfigViewModel> { export async function loadLeagueConfig(
leagueId: string,
): Promise<LeagueAdminConfigViewModel> {
const useCase = getGetLeagueFullConfigUseCase(); const useCase = getGetLeagueFullConfigUseCase();
const form = await useCase.execute({ leagueId }); const presenter = new LeagueFullConfigPresenter();
return { form };
await useCase.execute({ leagueId }, presenter);
const fullConfig = presenter.getViewModel();
if (!fullConfig) {
return { form: null };
}
const formModel: LeagueConfigFormModel = {
leagueId: fullConfig.leagueId,
basics: {
...fullConfig.basics,
visibility: fullConfig.basics.visibility as LeagueConfigFormModel['basics']['visibility'],
},
structure: {
...fullConfig.structure,
mode: fullConfig.structure.mode as LeagueConfigFormModel['structure']['mode'],
},
championships: fullConfig.championships,
scoring: fullConfig.scoring,
dropPolicy: {
strategy: fullConfig.dropPolicy.strategy as LeagueConfigFormModel['dropPolicy']['strategy'],
...(fullConfig.dropPolicy.n !== undefined ? { n: fullConfig.dropPolicy.n } : {}),
},
timings: fullConfig.timings,
stewarding: {
decisionMode: fullConfig.stewarding.decisionMode as LeagueConfigFormModel['stewarding']['decisionMode'],
...(fullConfig.stewarding.requiredVotes !== undefined
? { requiredVotes: fullConfig.stewarding.requiredVotes }
: {}),
requireDefense: fullConfig.stewarding.requireDefense,
defenseTimeLimit: fullConfig.stewarding.defenseTimeLimit,
voteTimeLimit: fullConfig.stewarding.voteTimeLimit,
protestDeadlineHours: fullConfig.stewarding.protestDeadlineHours,
stewardingClosesHours: fullConfig.stewarding.stewardingClosesHours,
notifyAccusedOnProtest: fullConfig.stewarding.notifyAccusedOnProtest,
notifyOnVoteRequired: fullConfig.stewarding.notifyOnVoteRequired,
},
};
return { form: formModel };
} }
/** /**

View File

@@ -42,8 +42,8 @@ export class LeagueDriverSeasonStatsPresenter implements ILeagueDriverSeasonStat
driverId: standing.driverId, driverId: standing.driverId,
position: standing.position, position: standing.position,
driverName: '', driverName: '',
teamId: undefined, teamId: '',
teamName: undefined, teamName: '',
totalPoints: standing.points + totalPenaltyPoints + bonusPoints, totalPoints: standing.points + totalPenaltyPoints + bonusPoints,
basePoints: standing.points, basePoints: standing.points,
penaltyPoints: Math.abs(totalPenaltyPoints), penaltyPoints: Math.abs(totalPenaltyPoints),

View File

@@ -8,7 +8,11 @@ import type {
export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter { export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
private viewModel: LeagueConfigFormViewModel | null = null; private viewModel: LeagueConfigFormViewModel | null = null;
present(data: LeagueFullConfigData): LeagueConfigFormViewModel { reset(): void {
this.viewModel = null;
}
present(data: LeagueFullConfigData): void {
const { league, activeSeason, scoringConfig, game } = data; const { league, activeSeason, scoringConfig, game } = data;
const patternId = scoringConfig?.scoringPresetId; const patternId = scoringConfig?.scoringPresetId;
@@ -32,12 +36,8 @@ export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
const roundsPlanned = 8; const roundsPlanned = 8;
let sessionCount = 2; let sessionCount = 2;
if ( if (primaryChampionship && Array.isArray(primaryChampionship.sessionTypes)) {
primaryChampionship && sessionCount = primaryChampionship.sessionTypes.length;
Array.isArray((primaryChampionship as any).sessionTypes) &&
(primaryChampionship as any).sessionTypes.length > 0
) {
sessionCount = (primaryChampionship as any).sessionTypes.length;
} }
const practiceMinutes = 20; const practiceMinutes = 20;
@@ -54,8 +54,6 @@ export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
structure: { structure: {
mode: 'solo', mode: 'solo',
maxDrivers: league.settings.maxDrivers ?? 32, maxDrivers: league.settings.maxDrivers ?? 32,
maxTeams: undefined,
driversPerTeam: undefined,
multiClassEnabled: false, multiClassEnabled: false,
}, },
championships: { championships: {
@@ -65,17 +63,19 @@ export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
enableTrophyChampionship: false, enableTrophyChampionship: false,
}, },
scoring: { scoring: {
patternId: patternId ?? undefined,
customScoringEnabled: !patternId, customScoringEnabled: !patternId,
...(patternId ? { patternId } : {}),
}, },
dropPolicy: dropPolicyForm, dropPolicy: dropPolicyForm,
timings: { timings: {
practiceMinutes, practiceMinutes,
qualifyingMinutes, qualifyingMinutes,
sprintRaceMinutes,
mainRaceMinutes, mainRaceMinutes,
sessionCount, sessionCount,
roundsPlanned, roundsPlanned,
...(typeof sprintRaceMinutes === 'number'
? { sprintRaceMinutes }
: {}),
}, },
stewarding: { stewarding: {
decisionMode: 'admin_only', decisionMode: 'admin_only',
@@ -88,11 +88,9 @@ export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
notifyOnVoteRequired: true, notifyOnVoteRequired: true,
}, },
}; };
return this.viewModel;
} }
getViewModel(): LeagueConfigFormViewModel { getViewModel(): LeagueConfigFormViewModel | null {
if (!this.viewModel) { if (!this.viewModel) {
throw new Error('Presenter has not been called yet'); throw new Error('Presenter has not been called yet');
} }

View File

@@ -1,5 +1,5 @@
import type { ILeagueSchedulePreviewPresenter } from '@racing/application/presenters/ILeagueSchedulePreviewPresenter'; import type { ILeagueSchedulePreviewPresenter } from '@gridpilot/racing/application/presenters/ILeagueSchedulePreviewPresenter';
import type { LeagueSchedulePreviewDTO } from '@racing/application/dto/LeagueScheduleDTO'; import type { LeagueSchedulePreviewDTO } from '@gridpilot/racing/application/dto/LeagueScheduleDTO';
export class LeagueSchedulePreviewPresenter implements ILeagueSchedulePreviewPresenter { export class LeagueSchedulePreviewPresenter implements ILeagueSchedulePreviewPresenter {
private data: LeagueSchedulePreviewDTO | null = null; private data: LeagueSchedulePreviewDTO | null = null;

View File

@@ -23,8 +23,8 @@ export class LeagueScoringConfigPresenter implements ILeagueScoringConfigPresent
seasonId: data.seasonId, seasonId: data.seasonId,
gameId: data.gameId, gameId: data.gameId,
gameName: data.gameName, gameName: data.gameName,
scoringPresetId: data.scoringPresetId, scoringPresetId: data.scoringPresetId ?? 'custom',
scoringPresetName: data.preset?.name, scoringPresetName: data.preset?.name ?? 'Custom',
dropPolicySummary, dropPolicySummary,
championships, championships,
}; };
@@ -61,7 +61,7 @@ export class LeagueScoringConfigPresenter implements ILeagueScoringConfigPresent
} }
private buildPointsPreview( private buildPointsPreview(
tables: Record<string, any>, tables: Record<string, { getPointsForPosition: (position: number) => number }>,
): Array<{ sessionType: string; position: number; points: number }> { ): Array<{ sessionType: string; position: number; points: number }> {
const preview: Array<{ const preview: Array<{
sessionType: string; sessionType: string;

View File

@@ -1,19 +1,23 @@
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
import type { import type {
ILeagueScoringPresetsPresenter, ILeagueScoringPresetsPresenter,
LeagueScoringPresetsViewModel, LeagueScoringPresetsViewModel,
LeagueScoringPresetsResultDTO,
} from '@gridpilot/racing/application/presenters/ILeagueScoringPresetsPresenter'; } from '@gridpilot/racing/application/presenters/ILeagueScoringPresetsPresenter';
export class LeagueScoringPresetsPresenter implements ILeagueScoringPresetsPresenter { export class LeagueScoringPresetsPresenter implements ILeagueScoringPresetsPresenter {
private viewModel: LeagueScoringPresetsViewModel | null = null; private viewModel: LeagueScoringPresetsViewModel | null = null;
present(presets: LeagueScoringPresetDTO[]): LeagueScoringPresetsViewModel { reset(): void {
this.viewModel = null;
}
present(dto: LeagueScoringPresetsResultDTO): void {
const { presets } = dto;
this.viewModel = { this.viewModel = {
presets, presets,
totalCount: presets.length, totalCount: presets.length,
}; };
return this.viewModel;
} }
getViewModel(): LeagueScoringPresetsViewModel { getViewModel(): LeagueScoringPresetsViewModel {

View File

@@ -1,38 +1,44 @@
import type { Standing } from '@gridpilot/racing/domain/entities/Standing';
import type { import type {
ILeagueStandingsPresenter, ILeagueStandingsPresenter,
StandingItemViewModel, LeagueStandingsResultDTO,
LeagueStandingsViewModel, LeagueStandingsViewModel,
StandingItemViewModel,
} from '@gridpilot/racing/application/presenters/ILeagueStandingsPresenter'; } from '@gridpilot/racing/application/presenters/ILeagueStandingsPresenter';
export class LeagueStandingsPresenter implements ILeagueStandingsPresenter { export class LeagueStandingsPresenter implements ILeagueStandingsPresenter {
private viewModel: LeagueStandingsViewModel | null = null; private viewModel: LeagueStandingsViewModel | null = null;
present(standings: Standing[]): LeagueStandingsViewModel { reset(): void {
const standingItems: StandingItemViewModel[] = standings.map((standing) => ({ this.viewModel = null;
}
present(dto: LeagueStandingsResultDTO): void {
const standingItems: StandingItemViewModel[] = dto.standings.map((standing) => {
const raw = standing as unknown as {
seasonId?: string;
podiums?: number;
};
return {
id: standing.id, id: standing.id,
leagueId: standing.leagueId, leagueId: standing.leagueId,
seasonId: standing.seasonId, seasonId: raw.seasonId ?? '',
driverId: standing.driverId, driverId: standing.driverId,
position: standing.position, position: standing.position,
points: standing.points, points: standing.points,
wins: standing.wins, wins: standing.wins,
podiums: standing.podiums, podiums: raw.podiums ?? 0,
racesCompleted: standing.racesCompleted, racesCompleted: standing.racesCompleted,
})); };
});
this.viewModel = { this.viewModel = {
leagueId: standings[0]?.leagueId ?? '', leagueId: dto.standings[0]?.leagueId ?? '',
standings: standingItems, standings: standingItems,
}; };
return this.viewModel;
} }
getViewModel(): LeagueStandingsViewModel { getViewModel(): LeagueStandingsViewModel | null {
if (!this.viewModel) {
throw new Error('Presenter has not been called yet');
}
return this.viewModel; return this.viewModel;
} }
} }

View File

@@ -1,14 +1,21 @@
import type { IPendingSponsorshipRequestsPresenter } from '@racing/application/presenters/IPendingSponsorshipRequestsPresenter'; import type {
import type { GetPendingSponsorshipRequestsResultDTO } from '@racing/application/use-cases/GetPendingSponsorshipRequestsUseCase'; IPendingSponsorshipRequestsPresenter,
PendingSponsorshipRequestsViewModel,
} from '@gridpilot/racing/application/presenters/IPendingSponsorshipRequestsPresenter';
import type { GetPendingSponsorshipRequestsResultDTO } from '@gridpilot/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
export class PendingSponsorshipRequestsPresenter implements IPendingSponsorshipRequestsPresenter { export class PendingSponsorshipRequestsPresenter implements IPendingSponsorshipRequestsPresenter {
private data: GetPendingSponsorshipRequestsResultDTO | null = null; private viewModel: PendingSponsorshipRequestsViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(data: GetPendingSponsorshipRequestsResultDTO): void { present(data: GetPendingSponsorshipRequestsResultDTO): void {
this.data = data; this.viewModel = data;
} }
getData(): GetPendingSponsorshipRequestsResultDTO | null { getViewModel(): PendingSponsorshipRequestsViewModel | null {
return this.data; return this.viewModel;
} }
} }

View File

@@ -1,60 +1,55 @@
import type { import type {
IRacePenaltiesPresenter, IRacePenaltiesPresenter,
RacePenaltyViewModel, RacePenaltyViewModel,
RacePenaltiesResultDTO,
RacePenaltiesViewModel, RacePenaltiesViewModel,
} from '@gridpilot/racing/application/presenters/IRacePenaltiesPresenter'; } from '@gridpilot/racing/application/presenters/IRacePenaltiesPresenter';
import type { PenaltyType, PenaltyStatus } from '@gridpilot/racing/domain/entities/Penalty';
export class RacePenaltiesPresenter implements IRacePenaltiesPresenter { export class RacePenaltiesPresenter implements IRacePenaltiesPresenter {
private viewModel: RacePenaltiesViewModel | null = null; private viewModel: RacePenaltiesViewModel | null = null;
present( reset(): void {
penalties: Array<{ this.viewModel = null;
id: string; }
raceId: string;
driverId: string; present(dto: RacePenaltiesResultDTO): void {
type: PenaltyType; const { penalties, driverMap } = dto;
value?: number;
reason: string; const penaltyViewModels: RacePenaltyViewModel[] = penalties.map((penalty) => {
protestId?: string; const value = typeof penalty.value === 'number' ? penalty.value : 0;
issuedBy: string; const protestId = penalty.protestId;
status: PenaltyStatus; const appliedAt = penalty.appliedAt ? penalty.appliedAt.toISOString() : undefined;
issuedAt: Date; const notes = penalty.notes;
appliedAt?: Date;
notes?: string; const base: RacePenaltyViewModel = {
getDescription(): string;
}>,
driverMap: Map<string, string>
): RacePenaltiesViewModel {
const penaltyViewModels: RacePenaltyViewModel[] = penalties.map(penalty => ({
id: penalty.id, id: penalty.id,
raceId: penalty.raceId, raceId: penalty.raceId,
driverId: penalty.driverId, driverId: penalty.driverId,
driverName: driverMap.get(penalty.driverId) || 'Unknown', driverName: driverMap.get(penalty.driverId) || 'Unknown',
type: penalty.type, type: penalty.type,
value: penalty.value, value,
reason: penalty.reason, reason: penalty.reason,
protestId: penalty.protestId,
issuedBy: penalty.issuedBy, issuedBy: penalty.issuedBy,
issuedByName: driverMap.get(penalty.issuedBy) || 'Unknown', issuedByName: driverMap.get(penalty.issuedBy) || 'Unknown',
status: penalty.status, status: penalty.status,
description: penalty.getDescription(), description: penalty.getDescription(),
issuedAt: penalty.issuedAt.toISOString(), issuedAt: penalty.issuedAt.toISOString(),
appliedAt: penalty.appliedAt?.toISOString(), };
notes: penalty.notes,
})); return {
...base,
...(protestId ? { protestId } : {}),
...(appliedAt ? { appliedAt } : {}),
...(typeof notes === 'string' && notes.length > 0 ? { notes } : {}),
};
});
this.viewModel = { this.viewModel = {
penalties: penaltyViewModels, penalties: penaltyViewModels,
}; };
return this.viewModel;
} }
getViewModel(): RacePenaltiesViewModel { getViewModel(): RacePenaltiesViewModel | null {
if (!this.viewModel) {
throw new Error('Presenter has not been called yet');
}
return this.viewModel; return this.viewModel;
} }
} }

View File

@@ -1,31 +1,22 @@
import type { import type {
IRaceProtestsPresenter, IRaceProtestsPresenter,
RaceProtestViewModel, RaceProtestViewModel,
RaceProtestsResultDTO,
RaceProtestsViewModel, RaceProtestsViewModel,
} from '@gridpilot/racing/application/presenters/IRaceProtestsPresenter'; } from '@gridpilot/racing/application/presenters/IRaceProtestsPresenter';
import type { ProtestStatus, ProtestIncident } from '@gridpilot/racing/domain/entities/Protest';
export class RaceProtestsPresenter implements IRaceProtestsPresenter { export class RaceProtestsPresenter implements IRaceProtestsPresenter {
private viewModel: RaceProtestsViewModel | null = null; private viewModel: RaceProtestsViewModel | null = null;
present( reset(): void {
protests: Array<{ this.viewModel = null;
id: string; }
raceId: string;
protestingDriverId: string; present(dto: RaceProtestsResultDTO): void {
accusedDriverId: string; const { protests, driverMap } = dto;
incident: ProtestIncident;
comment?: string; const protestViewModels: RaceProtestViewModel[] = protests.map((protest) => {
proofVideoUrl?: string; const base: RaceProtestViewModel = {
status: ProtestStatus;
reviewedBy?: string;
decisionNotes?: string;
filedAt: Date;
reviewedAt?: Date;
}>,
driverMap: Map<string, string>
): RaceProtestsViewModel {
const protestViewModels: RaceProtestViewModel[] = protests.map(protest => ({
id: protest.id, id: protest.id,
raceId: protest.raceId, raceId: protest.raceId,
protestingDriverId: protest.protestingDriverId, protestingDriverId: protest.protestingDriverId,
@@ -33,27 +24,37 @@ export class RaceProtestsPresenter implements IRaceProtestsPresenter {
accusedDriverId: protest.accusedDriverId, accusedDriverId: protest.accusedDriverId,
accusedDriverName: driverMap.get(protest.accusedDriverId) || 'Unknown', accusedDriverName: driverMap.get(protest.accusedDriverId) || 'Unknown',
incident: protest.incident, incident: protest.incident,
comment: protest.comment,
proofVideoUrl: protest.proofVideoUrl,
status: protest.status,
reviewedBy: protest.reviewedBy,
reviewedByName: protest.reviewedBy ? driverMap.get(protest.reviewedBy) : undefined,
decisionNotes: protest.decisionNotes,
filedAt: protest.filedAt.toISOString(), filedAt: protest.filedAt.toISOString(),
reviewedAt: protest.reviewedAt?.toISOString(), status: protest.status,
})); };
const comment = protest.comment;
const proofVideoUrl = protest.proofVideoUrl;
const reviewedBy = protest.reviewedBy;
const reviewedByName =
protest.reviewedBy !== undefined
? driverMap.get(protest.reviewedBy) ?? 'Unknown'
: undefined;
const decisionNotes = protest.decisionNotes;
const reviewedAt = protest.reviewedAt?.toISOString();
return {
...base,
...(comment !== undefined ? { comment } : {}),
...(proofVideoUrl !== undefined ? { proofVideoUrl } : {}),
...(reviewedBy !== undefined ? { reviewedBy } : {}),
...(reviewedByName !== undefined ? { reviewedByName } : {}),
...(decisionNotes !== undefined ? { decisionNotes } : {}),
...(reviewedAt !== undefined ? { reviewedAt } : {}),
};
});
this.viewModel = { this.viewModel = {
protests: protestViewModels, protests: protestViewModels,
}; };
return this.viewModel;
} }
getViewModel(): RaceProtestsViewModel { getViewModel(): RaceProtestsViewModel | null {
if (!this.viewModel) {
throw new Error('Presenter has not been called yet');
}
return this.viewModel; return this.viewModel;
} }
} }

View File

@@ -4,26 +4,59 @@ import type {
RaceListItemViewModel, RaceListItemViewModel,
} from '@gridpilot/racing/application/presenters/IRacesPagePresenter'; } from '@gridpilot/racing/application/presenters/IRacesPagePresenter';
interface RacesPageInput {
id: string;
track: string;
car: string;
scheduledAt: string | Date;
status: string;
leagueId: string;
leagueName: string;
strengthOfField: number | null;
isUpcoming: boolean;
isLive: boolean;
isPast: boolean;
}
export class RacesPagePresenter implements IRacesPagePresenter { export class RacesPagePresenter implements IRacesPagePresenter {
private viewModel: RacesPageViewModel | null = null; private viewModel: RacesPageViewModel | null = null;
present(races: any[]): void { present(races: RacesPageInput[]): void {
const now = new Date(); const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const raceViewModels: RaceListItemViewModel[] = races.map(race => ({ const raceViewModels: RaceListItemViewModel[] = races.map((race) => {
const scheduledAt =
typeof race.scheduledAt === 'string'
? race.scheduledAt
: race.scheduledAt.toISOString();
const allowedStatuses: RaceListItemViewModel['status'][] = [
'scheduled',
'running',
'completed',
'cancelled',
];
const status: RaceListItemViewModel['status'] =
allowedStatuses.includes(race.status as RaceListItemViewModel['status'])
? (race.status as RaceListItemViewModel['status'])
: 'scheduled';
return {
id: race.id, id: race.id,
track: race.track, track: race.track,
car: race.car, car: race.car,
scheduledAt: race.scheduledAt, scheduledAt,
status: race.status, status,
leagueId: race.leagueId, leagueId: race.leagueId,
leagueName: race.leagueName, leagueName: race.leagueName,
strengthOfField: race.strengthOfField, strengthOfField: race.strengthOfField,
isUpcoming: race.isUpcoming, isUpcoming: race.isUpcoming,
isLive: race.isLive, isLive: race.isLive,
isPast: race.isPast, isPast: race.isPast,
})); };
});
const stats = { const stats = {
total: raceViewModels.length, total: raceViewModels.length,

View File

@@ -1,5 +1,5 @@
import type { ISponsorDashboardPresenter } from '@racing/application/presenters/ISponsorDashboardPresenter'; import type { ISponsorDashboardPresenter } from '@gridpilot/racing/application/presenters/ISponsorDashboardPresenter';
import type { SponsorDashboardDTO } from '@racing/application/use-cases/GetSponsorDashboardUseCase'; import type { SponsorDashboardDTO } from '@gridpilot/racing/application/use-cases/GetSponsorDashboardUseCase';
export class SponsorDashboardPresenter implements ISponsorDashboardPresenter { export class SponsorDashboardPresenter implements ISponsorDashboardPresenter {
private data: SponsorDashboardDTO | null = null; private data: SponsorDashboardDTO | null = null;

View File

@@ -1,5 +1,5 @@
import type { ISponsorSponsorshipsPresenter } from '@racing/application/presenters/ISponsorSponsorshipsPresenter'; import type { ISponsorSponsorshipsPresenter } from '@gridpilot/racing/application/presenters/ISponsorSponsorshipsPresenter';
import type { SponsorSponsorshipsDTO } from '@racing/application/use-cases/GetSponsorSponsorshipsUseCase'; import type { SponsorSponsorshipsDTO } from '@gridpilot/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
export class SponsorSponsorshipsPresenter implements ISponsorSponsorshipsPresenter { export class SponsorSponsorshipsPresenter implements ISponsorSponsorshipsPresenter {
private data: SponsorSponsorshipsDTO | null = null; private data: SponsorSponsorshipsDTO | null = null;

View File

@@ -1,4 +1,3 @@
import type { Team, TeamJoinRequest } from '@gridpilot/racing';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import { import {
@@ -34,7 +33,9 @@ export interface TeamAdminViewModel {
/** /**
* Load join requests plus driver DTOs for a team. * Load join requests plus driver DTOs for a team.
*/ */
export async function loadTeamAdminViewModel(team: Team): Promise<TeamAdminViewModel> { export async function loadTeamAdminViewModel(
team: TeamAdminTeamSummaryViewModel,
): Promise<TeamAdminViewModel> {
const requests = await loadTeamJoinRequests(team.id); const requests = await loadTeamJoinRequests(team.id);
return { return {
team: { team: {
@@ -48,10 +49,18 @@ export async function loadTeamAdminViewModel(team: Team): Promise<TeamAdminViewM
}; };
} }
export async function loadTeamJoinRequests(teamId: string): Promise<TeamAdminJoinRequestViewModel[]> { export async function loadTeamJoinRequests(
teamId: string,
): Promise<TeamAdminJoinRequestViewModel[]> {
const getRequestsUseCase = getGetTeamJoinRequestsUseCase(); const getRequestsUseCase = getGetTeamJoinRequestsUseCase();
await getRequestsUseCase.execute({ teamId }); const presenter = new (await import('./TeamJoinRequestsPresenter')).TeamJoinRequestsPresenter();
const presenterVm = getRequestsUseCase.presenter.getViewModel();
await getRequestsUseCase.execute({ teamId }, presenter);
const presenterVm = presenter.getViewModel();
if (!presenterVm) {
return [];
}
const driverRepo = getDriverRepository(); const driverRepo = getDriverRepository();
const allDrivers = await driverRepo.findAll(); const allDrivers = await driverRepo.findAll();
@@ -64,14 +73,29 @@ export async function loadTeamJoinRequests(teamId: string): Promise<TeamAdminJoi
} }
} }
return presenterVm.requests.map((req) => ({ return presenterVm.requests.map((req: {
requestId: string;
teamId: string;
driverId: string;
requestedAt: string;
message?: string;
}): TeamAdminJoinRequestViewModel => {
const base: TeamAdminJoinRequestViewModel = {
id: req.requestId, id: req.requestId,
teamId: req.teamId, teamId: req.teamId,
driverId: req.driverId, driverId: req.driverId,
requestedAt: new Date(req.requestedAt), requestedAt: new Date(req.requestedAt),
message: req.message, };
driver: driversById[req.driverId],
})); const message = req.message;
const driver = driversById[req.driverId];
return {
...base,
...(message !== undefined ? { message } : {}),
...(driver !== undefined ? { driver } : {}),
};
});
} }
/** /**

View File

@@ -1,4 +1,5 @@
import type { Team, TeamMembership } from '@gridpilot/racing/domain/entities/Team'; import type { Team } from '@gridpilot/racing/domain/entities/Team';
import type { TeamMembership } from '@gridpilot/racing/domain/types/TeamMembership';
import type { import type {
ITeamDetailsPresenter, ITeamDetailsPresenter,
TeamDetailsViewModel, TeamDetailsViewModel,
@@ -14,7 +15,7 @@ export class TeamDetailsPresenter implements ITeamDetailsPresenter {
): TeamDetailsViewModel { ): TeamDetailsViewModel {
const canManage = membership?.role === 'owner' || membership?.role === 'manager'; const canManage = membership?.role === 'owner' || membership?.role === 'manager';
this.viewModel = { const viewModel: TeamDetailsViewModel = {
team: { team: {
id: team.id, id: team.id,
name: team.name, name: team.name,
@@ -22,21 +23,20 @@ export class TeamDetailsPresenter implements ITeamDetailsPresenter {
description: team.description, description: team.description,
ownerId: team.ownerId, ownerId: team.ownerId,
leagues: team.leagues, leagues: team.leagues,
specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined,
region: team.region,
languages: team.languages,
}, },
membership: membership membership: membership
? { ? {
role: membership.role, role: membership.role === 'driver' ? 'member' : membership.role,
joinedAt: membership.joinedAt.toISOString(), joinedAt: membership.joinedAt.toISOString(),
isActive: membership.isActive, isActive: membership.status === 'active',
} }
: null, : null,
canManage, canManage,
}; };
return this.viewModel; this.viewModel = viewModel;
return viewModel;
} }
getViewModel(): TeamDetailsViewModel { getViewModel(): TeamDetailsViewModel {

View File

@@ -1,26 +1,26 @@
import type { TeamJoinRequest } from '@gridpilot/racing/domain/entities/Team';
import type { import type {
ITeamJoinRequestsPresenter, ITeamJoinRequestsPresenter,
TeamJoinRequestViewModel, TeamJoinRequestViewModel,
TeamJoinRequestsViewModel, TeamJoinRequestsViewModel,
TeamJoinRequestsResultDTO,
} from '@gridpilot/racing/application/presenters/ITeamJoinRequestsPresenter'; } from '@gridpilot/racing/application/presenters/ITeamJoinRequestsPresenter';
export class TeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter { export class TeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter {
private viewModel: TeamJoinRequestsViewModel | null = null; private viewModel: TeamJoinRequestsViewModel | null = null;
present( reset(): void {
requests: TeamJoinRequest[], this.viewModel = null;
driverNames: Record<string, string>, }
avatarUrls: Record<string, string>
): TeamJoinRequestsViewModel { present(input: TeamJoinRequestsResultDTO): void {
const requestItems: TeamJoinRequestViewModel[] = requests.map((request) => ({ const requestItems: TeamJoinRequestViewModel[] = input.requests.map((request) => ({
requestId: request.id, requestId: request.id,
driverId: request.driverId, driverId: request.driverId,
driverName: driverNames[request.driverId] ?? 'Unknown Driver', driverName: input.driverNames[request.driverId] ?? 'Unknown Driver',
teamId: request.teamId, teamId: request.teamId,
status: request.status, status: 'pending',
requestedAt: request.requestedAt.toISOString(), requestedAt: request.requestedAt.toISOString(),
avatarUrl: avatarUrls[request.driverId] ?? '', avatarUrl: input.avatarUrls[request.driverId] ?? '',
})); }));
const pendingCount = requestItems.filter((r) => r.status === 'pending').length; const pendingCount = requestItems.filter((r) => r.status === 'pending').length;
@@ -30,14 +30,9 @@ export class TeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter {
pendingCount, pendingCount,
totalCount: requestItems.length, totalCount: requestItems.length,
}; };
return this.viewModel;
} }
getViewModel(): TeamJoinRequestsViewModel { getViewModel(): TeamJoinRequestsViewModel | null {
if (!this.viewModel) {
throw new Error('Presenter has not been called yet');
}
return this.viewModel; return this.viewModel;
} }
} }

View File

@@ -1,25 +1,25 @@
import type { TeamMembership } from '@gridpilot/racing/domain/entities/Team';
import type { import type {
ITeamMembersPresenter, ITeamMembersPresenter,
TeamMemberViewModel, TeamMemberViewModel,
TeamMembersViewModel, TeamMembersViewModel,
TeamMembersResultDTO,
} from '@gridpilot/racing/application/presenters/ITeamMembersPresenter'; } from '@gridpilot/racing/application/presenters/ITeamMembersPresenter';
export class TeamMembersPresenter implements ITeamMembersPresenter { export class TeamMembersPresenter implements ITeamMembersPresenter {
private viewModel: TeamMembersViewModel | null = null; private viewModel: TeamMembersViewModel | null = null;
present( reset(): void {
memberships: TeamMembership[], this.viewModel = null;
driverNames: Record<string, string>, }
avatarUrls: Record<string, string>
): TeamMembersViewModel { present(input: TeamMembersResultDTO): void {
const members: TeamMemberViewModel[] = memberships.map((membership) => ({ const members: TeamMemberViewModel[] = input.memberships.map((membership) => ({
driverId: membership.driverId, driverId: membership.driverId,
driverName: driverNames[membership.driverId] ?? 'Unknown Driver', driverName: input.driverNames[membership.driverId] ?? 'Unknown Driver',
role: membership.role, role: membership.role === 'driver' ? 'member' : membership.role,
joinedAt: membership.joinedAt.toISOString(), joinedAt: membership.joinedAt.toISOString(),
isActive: membership.isActive, isActive: membership.status === 'active',
avatarUrl: avatarUrls[membership.driverId] ?? '', avatarUrl: input.avatarUrls[membership.driverId] ?? '',
})); }));
const ownerCount = members.filter((m) => m.role === 'owner').length; const ownerCount = members.filter((m) => m.role === 'owner').length;
@@ -33,14 +33,9 @@ export class TeamMembersPresenter implements ITeamMembersPresenter {
managerCount, managerCount,
memberCount, memberCount,
}; };
return this.viewModel;
} }
getViewModel(): TeamMembersViewModel { getViewModel(): TeamMembersViewModel | null {
if (!this.viewModel) {
throw new Error('Presenter has not been called yet');
}
return this.viewModel; return this.viewModel;
} }
} }

View File

@@ -1,4 +1,4 @@
import type { TeamMembership, TeamRole } from '@gridpilot/racing'; import type { TeamMembership, TeamRole } from '@gridpilot/racing/domain/types/TeamMembership';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import { getDriverRepository, getDriverStats } from '@/lib/di-container'; import { getDriverRepository, getDriverStats } from '@/lib/di-container';

View File

@@ -3,12 +3,36 @@ import type {
TeamsLeaderboardViewModel, TeamsLeaderboardViewModel,
TeamLeaderboardItemViewModel, TeamLeaderboardItemViewModel,
SkillLevel, SkillLevel,
TeamsLeaderboardResultDTO,
} from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter'; } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
interface TeamLeaderboardInput {
id: string;
name: string;
memberCount: number;
rating: number | null;
totalWins: number;
totalRaces: number;
performanceLevel: SkillLevel;
isRecruiting: boolean;
createdAt: Date;
description?: string | null;
specialization?: string | null;
region?: string | null;
languages?: string[];
}
export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter { export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
private viewModel: TeamsLeaderboardViewModel | null = null; private viewModel: TeamsLeaderboardViewModel | null = null;
present(teams: any[], recruitingCount: number): void { reset(): void {
this.viewModel = null;
}
present(input: TeamsLeaderboardResultDTO): void {
const teams = (input.teams ?? []) as TeamLeaderboardInput[];
const recruitingCount = input.recruitingCount ?? 0;
const transformedTeams = teams.map((team) => this.transformTeam(team)); const transformedTeams = teams.map((team) => this.transformTeam(team));
const groupsBySkillLevel = transformedTeams.reduce<Record<SkillLevel, TeamLeaderboardItemViewModel[]>>( const groupsBySkillLevel = transformedTeams.reduce<Record<SkillLevel, TeamLeaderboardItemViewModel[]>>(
@@ -41,14 +65,22 @@ export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
}; };
} }
getViewModel(): TeamsLeaderboardViewModel { getViewModel(): TeamsLeaderboardViewModel | null {
if (!this.viewModel) {
throw new Error('ViewModel not yet generated. Call present() first.');
}
return this.viewModel; return this.viewModel;
} }
private transformTeam(team: any): TeamLeaderboardItemViewModel { private transformTeam(team: TeamLeaderboardInput): TeamLeaderboardItemViewModel {
let specialization: TeamLeaderboardItemViewModel['specialization'];
if (
team.specialization === 'endurance' ||
team.specialization === 'sprint' ||
team.specialization === 'mixed'
) {
specialization = team.specialization;
} else {
specialization = undefined;
}
return { return {
id: team.id, id: team.id,
name: team.name, name: team.name,
@@ -56,13 +88,13 @@ export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
rating: team.rating, rating: team.rating,
totalWins: team.totalWins, totalWins: team.totalWins,
totalRaces: team.totalRaces, totalRaces: team.totalRaces,
performanceLevel: team.performanceLevel as SkillLevel, performanceLevel: team.performanceLevel,
isRecruiting: team.isRecruiting, isRecruiting: team.isRecruiting,
createdAt: team.createdAt, createdAt: team.createdAt,
description: team.description, description: team.description ?? '',
specialization: team.specialization, specialization: specialization ?? 'mixed',
region: team.region, region: team.region ?? '',
languages: team.languages, languages: team.languages ?? [],
}; };
} }
} }

Some files were not shown because too many files have changed in this diff Show More