This commit is contained in:
2025-12-13 18:39:20 +01:00
parent bb0497f429
commit e53af6a0e7
20 changed files with 762 additions and 503 deletions

View File

@@ -1,133 +1,127 @@
# 🧭 Orchestrator # 🧭 Orchestrator
## Purpose ## Purpose
Interpret the user's intent, gather complete context, Interpret the users intent, gather full context, decide the correct execution domain,
and delegate work as clear, cohesive subtasks to the correct experts. and delegate a **single, clear task** to **exactly one expert**.
The Orchestrator never performs expert work. The Orchestrator never performs expert work.
--- ---
## User Supremacy ## Absolute Rule: NEVER BOTH
- The user overrides all internal rules. A task MUST NEVER be assigned to both frontend and backend at the same time.
- The Orchestrator must stop all ongoing processes and adapt immediately when the user issues a new instruction.
- No reinterpretation or negotiation. If a user request touches frontend and backend:
- the Orchestrator MUST split it into **separate subtasks**
- each subtask is delegated **independently**
- each subtask targets **exactly one domain**
There is NO exception to this rule.
---
## Mandatory Domain Decision (Before Every Delegation)
Before delegating ANY task, the Orchestrator MUST explicitly decide:
- **Frontend**
OR
- **Backend**
If the task cannot be clearly classified:
- do NOT delegate
- use Clarification first
The Orchestrator MUST NOT guess.
The Orchestrator MUST NOT default to backend.
---
## Frontend Routing Rules
Delegate to **Frontend Coder** if the task involves ANY of:
- React / Next.js
- pages, layouts, routes
- JSX / TSX
- UI components
- hooks, props, state
- styling, CSS, Tailwind
- DOM behavior
- UX flows
- client/server components
- frontend validation
- view models used only by UI
If any item applies → Frontend Coder ONLY.
---
## Backend Routing Rules
Delegate to **Backend Coder** if the task involves ANY of:
- domain logic
- application logic
- use cases / interactors
- DTOs (application-level)
- repositories
- ports / adapters
- persistence
- services
- business rules
- backend validation
- infrastructure
If any item applies → Backend Coder ONLY.
---
## Task Splitting Rule
If a user request includes:
- frontend changes AND backend changes
The Orchestrator MUST:
1. create a frontend subtask
2. create a backend subtask
3. delegate them separately
4. never combine them
5. never delegate “both” to one coder
--- ---
## Context Responsibility ## Context Responsibility
The Orchestrator MUST provide: For every delegation, the Orchestrator MUST provide:
- exact file paths - exact file paths
- content excerpts when needed - exact scope
- exact operations
- constraints - constraints
- expected output
- what must NOT be touched - what must NOT be touched
- any relevant test or behavior definition - expected outcome
Experts must NEVER collect context themselves. Experts MUST NOT gather context themselves.
--- ---
## Task Grouping ## User Supremacy
The Orchestrator MUST: If the user explicitly says:
- **merge related work into one cohesive subtask** - “ignore separation”
- **split unrelated work into multiple subtasks** - “do frontend only”
- assign each subtask to exactly one expert - “do backend only”
- never mix concerns or layers
A subtask must always be: The Orchestrator MUST obey exactly as stated.
- self-contained
- minimal
- fully scoped
- executable
---
## TODO List Responsibility (Critical)
The Orchestrator MUST maintain a **strict, accurate TODO list**.
Rules:
1. When the user gives ANY instruction →
**the Orchestrator MUST generate or update a TODO list.**
2. TODO list must contain **ONLY outstanding, unfinished work**.
- No completed items.
- No redundant items.
- No invented tasks.
- No assumptions.
3. Each TODO item must be:
- explicit
- actionable
- minimal
- atomic (one responsibility per item)
4. The TODO list MUST represent the **true, current state** of what remains.
- If something is already done → DO NOT list it
- If something is irrelevant → DO NOT list it
- If something is repeated → collapse to one item
5. The TODO list is the **single source of truth** for remaining work.
6. Experts NEVER update TODOs.
Only the Orchestrator modifies TODOs.
7. After each expert result:
- The Orchestrator MUST update the TODO list (finish/remove completed items, keep only outstanding ones).
---
## Delegation Rules
A delegation MUST be:
- direct
- unambiguous
- fully scoped
- context-complete
- zero explanations
- no options
- no reasoning
Format guidelines:
- “Here is the context.”
- “Here is the task.”
- “Do exactly this and nothing else.”
---
## Interruptibility
When the user issues a new instruction:
- stop all running tasks
- discard previous assumptions
- rebuild TODO list
- delegate new work
---
## Efficiency
The Orchestrator MUST:
- minimize the number of subtasks
- avoid duplicated work
- ensure no overlapping instructions
- keep the workflow deterministic
--- ---
## Forbidden ## Forbidden
The Orchestrator MUST NOT: The Orchestrator MUST NOT:
- perform expert-level reasoning - delegate mixed frontend/backend tasks
- propose solutions - say “both”
- give architecture opinions - let coders decide responsibility
- write plans - assume backend by default
- describe implementations - rush to delegation without classification
- output long explanations - merge unrelated work
- generate TODOs that are already done
- expand or reduce user intent
- run tests
- edit files
--- ---
## Completion ## Completion
A step is complete when: A delegation is valid only when:
- the assigned expert returns the result - exactly one domain is chosen
- the TODO list is updated to reflect ONLY what is still outstanding - exactly one expert is assigned
- the Orchestrator either delegates the next TODO or waits for user input - context is complete
- task scope is single-purpose

View File

@@ -6,6 +6,7 @@ import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton'; import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed'; import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed';
import EndRaceModal from '@/components/leagues/EndRaceModal';
import DriverIdentity from '@/components/drivers/DriverIdentity'; import DriverIdentity from '@/components/drivers/DriverIdentity';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import SponsorInsightsCard, { import SponsorInsightsCard, {
@@ -68,6 +69,7 @@ export default function LeagueDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [endRaceModalRaceId, setEndRaceModalRaceId] = useState<string | null>(null);
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
const membership = getMembership(leagueId, currentDriverId); const membership = getMembership(leagueId, currentDriverId);
@@ -347,22 +349,7 @@ export default function LeagueDetailPage() {
{membership?.role === 'admin' && ( {membership?.role === 'admin' && (
<Button <Button
variant="secondary" variant="secondary"
onClick={async () => { onClick={() => setEndRaceModalRaceId(race.id)}
const confirmed = window.confirm(
'Are you sure you want to end this race and process results?\n\nThis will mark the race as completed and calculate final standings.'
);
if (!confirmed) return;
try {
const completeRace = getCompleteRaceUseCase();
await completeRace.execute({ raceId: race.id });
// Reload league data to reflect the completed race
await loadLeagueData();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to complete race');
}
}}
className="border-performance-green/50 text-performance-green hover:bg-performance-green/10" className="border-performance-green/50 text-performance-green hover:bg-performance-green/10"
> >
End Race & Process Results End Race & Process Results
@@ -684,6 +671,28 @@ export default function LeagueDetailPage() {
)} )}
</div> </div>
</div> </div>
{/* End Race Modal */}
{endRaceModalRaceId && (() => {
const race = runningRaces.find(r => r.id === endRaceModalRaceId);
return race ? (
<EndRaceModal
raceId={race.id}
raceName={race.track}
onConfirm={async () => {
try {
const completeRace = getCompleteRaceUseCase();
await completeRace.execute({ raceId: race.id });
await loadLeagueData();
setEndRaceModalRaceId(null);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to complete race');
}
}}
onCancel={() => setEndRaceModalRaceId(null)}
/>
) : null;
})()}
</> </>
); );
} }

View File

@@ -6,6 +6,8 @@ import Link from 'next/link';
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 { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal'; import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
import PenaltyFAB from '@/components/leagues/PenaltyFAB';
import { import {
getRaceRepository, getRaceRepository,
getProtestRepository, getProtestRepository,
@@ -43,11 +45,13 @@ export default function LeagueStewardingPage() {
const [protestsByRace, setProtestsByRace] = useState<Record<string, Protest[]>>({}); const [protestsByRace, setProtestsByRace] = useState<Record<string, Protest[]>>({});
const [penaltiesByRace, setPenaltiesByRace] = useState<Record<string, Penalty[]>>({}); const [penaltiesByRace, setPenaltiesByRace] = useState<Record<string, Penalty[]>>({});
const [driversById, setDriversById] = useState<Record<string, DriverDTO>>({}); const [driversById, setDriversById] = useState<Record<string, DriverDTO>>({});
const [allDrivers, setAllDrivers] = useState<DriverDTO[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending'); const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<Protest | null>(null); const [selectedProtest, setSelectedProtest] = useState<Protest | null>(null);
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set()); const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
useEffect(() => { useEffect(() => {
async function checkAdmin() { async function checkAdmin() {
@@ -110,6 +114,7 @@ export default function LeagueStewardingPage() {
} }
}); });
setDriversById(byId); setDriversById(byId);
setAllDrivers(Object.values(byId));
// Auto-expand races with pending protests // Auto-expand races with pending protests
const racesWithPending = new Set<string>(); const racesWithPending = new Set<string>();
@@ -495,6 +500,10 @@ export default function LeagueStewardingPage() {
)} )}
</Card> </Card>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && ( {selectedProtest && (
<ReviewProtestModal <ReviewProtestModal
protest={selectedProtest} protest={selectedProtest}
@@ -503,6 +512,15 @@ export default function LeagueStewardingPage() {
onReject={handleRejectProtest} onReject={handleRejectProtest}
/> />
)} )}
{showQuickPenaltyModal && (
<QuickPenaltyModal
drivers={allDrivers}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId}
races={races.map(r => ({ id: r.id, track: r.track, scheduledAt: r.scheduledAt }))}
/>
)}
</div> </div>
); );
} }

View File

@@ -8,6 +8,7 @@ import Card from '@/components/ui/Card';
import Heading from '@/components/ui/Heading'; import Heading from '@/components/ui/Heading';
import Breadcrumbs from '@/components/layout/Breadcrumbs'; import Breadcrumbs from '@/components/layout/Breadcrumbs';
import FileProtestModal from '@/components/races/FileProtestModal'; import FileProtestModal from '@/components/races/FileProtestModal';
import EndRaceModal from '@/components/leagues/EndRaceModal';
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard'; import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
import { getGetRaceDetailUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, getCancelRaceUseCase, getCompleteRaceUseCase } from '@/lib/di-container'; import { getGetRaceDetailUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, getCancelRaceUseCase, getCompleteRaceUseCase } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver'; import { useEffectiveDriverId } from '@/lib/currentDriver';
@@ -50,6 +51,7 @@ export default function RaceDetailPage() {
const [ratingChange, setRatingChange] = useState<number | null>(null); const [ratingChange, setRatingChange] = useState<number | null>(null);
const [animatedRatingChange, setAnimatedRatingChange] = useState(0); const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
const [showProtestModal, setShowProtestModal] = useState(false); const [showProtestModal, setShowProtestModal] = useState(false);
const [showEndRaceModal, setShowEndRaceModal] = useState(false);
const [membership, setMembership] = useState<any>(null); const [membership, setMembership] = useState<any>(null);
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
@@ -928,22 +930,7 @@ export default function RaceDetailPage() {
<Button <Button
variant="primary" variant="primary"
className="w-full flex items-center justify-center gap-2" className="w-full flex items-center justify-center gap-2"
onClick={async () => { onClick={() => setShowEndRaceModal(true)}
const confirmed = window.confirm(
'Are you sure you want to end this race and process results?\n\nThis will mark the race as completed and calculate final standings.'
);
if (!confirmed) return;
try {
const completeRace = getCompleteRaceUseCase();
await completeRace.execute({ raceId: race.id });
// Reload race data to reflect the completed race
await loadRaceData();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to complete race');
}
}}
> >
<CheckCircle2 className="w-4 h-4" /> <CheckCircle2 className="w-4 h-4" />
End Race & Process Results End Race & Process Results
@@ -989,6 +976,25 @@ export default function RaceDetailPage() {
protestingDriverId={currentDriverId} protestingDriverId={currentDriverId}
participants={entryList.map(d => ({ id: d.id, name: d.name }))} participants={entryList.map(d => ({ id: d.id, name: d.name }))}
/> />
{/* End Race Modal */}
{showEndRaceModal && (
<EndRaceModal
raceId={race.id}
raceName={race.track}
onConfirm={async () => {
try {
const completeRace = getCompleteRaceUseCase();
await completeRace.execute({ raceId: race.id });
await loadRaceData();
setShowEndRaceModal(false);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to complete race');
}
}}
onCancel={() => setShowEndRaceModal(false)}
/>
)}
</div> </div>
); );
} }

View File

@@ -8,11 +8,15 @@ import Card from '@/components/ui/Card';
import Breadcrumbs from '@/components/layout/Breadcrumbs'; import Breadcrumbs from '@/components/layout/Breadcrumbs';
import ResultsTable from '@/components/races/ResultsTable'; import ResultsTable from '@/components/races/ResultsTable';
import ImportResultsForm from '@/components/races/ImportResultsForm'; import ImportResultsForm from '@/components/races/ImportResultsForm';
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
import { import {
getGetRaceWithSOFUseCase, getGetRaceWithSOFUseCase,
getGetRaceResultsDetailUseCase, getGetRaceResultsDetailUseCase,
getImportRaceResultsUseCase, getImportRaceResultsUseCase,
getLeagueMembershipRepository,
} from '@/lib/di-container'; } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import { RaceWithSOFPresenter } from '@/lib/presenters/RaceWithSOFPresenter'; import { RaceWithSOFPresenter } from '@/lib/presenters/RaceWithSOFPresenter';
import { RaceResultsDetailPresenter } from '@/lib/presenters/RaceResultsDetailPresenter'; import { RaceResultsDetailPresenter } from '@/lib/presenters/RaceResultsDetailPresenter';
import type { import type {
@@ -65,12 +69,12 @@ export default function RaceResultsPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const raceId = params.id as string; const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const [race, setRace] = useState<RaceResultsHeaderViewModel | null>(null); const [race, setRace] = useState<RaceResultsHeaderViewModel | null>(null);
const [league, setLeague] = useState<RaceResultsLeagueViewModel | null>(null); const [league, setLeague] = useState<RaceResultsLeagueViewModel | null>(null);
const [results, setResults] = useState<RaceResultRowDTO[]>([]); const [results, setResults] = useState<RaceResultRowDTO[]>([]);
const [drivers, setDrivers] = useState<DriverRowDTO[]>([]); const [drivers, setDrivers] = useState<DriverRowDTO[]>([]);
const [currentDriverId, setCurrentDriverId] = useState<string | undefined>(undefined);
const [raceSOF, setRaceSOF] = useState<number | null>(null); const [raceSOF, setRaceSOF] = useState<number | null>(null);
const [penalties, setPenalties] = useState<PenaltyData[]>([]); const [penalties, setPenalties] = useState<PenaltyData[]>([]);
const [pointsSystem, setPointsSystem] = useState<Record<number, number> | undefined>(undefined); const [pointsSystem, setPointsSystem] = useState<Record<number, number> | undefined>(undefined);
@@ -79,6 +83,9 @@ export default function RaceResultsPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [importSuccess, setImportSuccess] = useState(false); const [importSuccess, setImportSuccess] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
const [preSelectedDriver, setPreSelectedDriver] = useState<DriverRowDTO | undefined>(undefined);
const loadData = async () => { const loadData = async () => {
try { try {
@@ -103,7 +110,6 @@ export default function RaceResultsPage() {
setPenalties([]); setPenalties([]);
setPointsSystem({}); setPointsSystem({});
setFastestLapTime(undefined); setFastestLapTime(undefined);
setCurrentDriverId(undefined);
} else { } else {
setError(null); setError(null);
setRace(viewModel.race); setRace(viewModel.race);
@@ -117,7 +123,6 @@ export default function RaceResultsPage() {
); );
setPointsSystem(viewModel.pointsSystem); setPointsSystem(viewModel.pointsSystem);
setFastestLapTime(viewModel.fastestLapTime); setFastestLapTime(viewModel.fastestLapTime);
setCurrentDriverId(viewModel.currentDriverId);
const mappedPenalties: PenaltyData[] = viewModel.penalties.map((p) => { const mappedPenalties: PenaltyData[] = viewModel.penalties.map((p) => {
const base: PenaltyData = { const base: PenaltyData = {
driverId: p.driverId, driverId: p.driverId,
@@ -154,6 +159,17 @@ export default function RaceResultsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [raceId]); }, [raceId]);
useEffect(() => {
if (league?.id && currentDriverId) {
const checkAdmin = async () => {
const membershipRepo = getLeagueMembershipRepository();
const membership = await membershipRepo.getMembership(league.id, currentDriverId);
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
};
checkAdmin();
}
}, [league?.id, currentDriverId]);
const handleImportSuccess = async (importedResults: ImportResultRowDTO[]) => { const handleImportSuccess = async (importedResults: ImportResultRowDTO[]) => {
setImporting(true); setImporting(true);
setError(null); setError(null);
@@ -178,6 +194,16 @@ export default function RaceResultsPage() {
setError(errorMessage); setError(errorMessage);
}; };
const handlePenaltyClick = (driver: DriverRowDTO) => {
setPreSelectedDriver(driver);
setShowQuickPenaltyModal(true);
};
const handleCloseQuickPenaltyModal = () => {
setShowQuickPenaltyModal(false);
setPreSelectedDriver(undefined);
};
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
@@ -203,6 +229,19 @@ export default function RaceResultsPage() {
Back to Races Back to Races
</Button> </Button>
</Card> </Card>
{showQuickPenaltyModal && (
<QuickPenaltyModal
{...({
raceId,
drivers: drivers as any,
onClose: handleCloseQuickPenaltyModal,
preSelectedDriver: preSelectedDriver as any,
adminId: currentDriverId,
races: undefined,
} as any)}
/>
)}
</div> </div>
</div> </div>
); );
@@ -298,6 +337,8 @@ export default function RaceResultsPage() {
fastestLapTime={fastestLapTime ?? 0} fastestLapTime={fastestLapTime ?? 0}
penalties={penalties} penalties={penalties}
currentDriverId={currentDriverId ?? ''} currentDriverId={currentDriverId ?? ''}
isAdmin={isAdmin}
onPenaltyClick={handlePenaltyClick}
/> />
) : ( ) : (
<> <>

View File

@@ -0,0 +1,57 @@
'use client';
import { useState } from 'react';
import { MoreVertical, Edit, Trash2 } from 'lucide-react';
import Button from '../ui/Button';
interface PenaltyCardMenuProps {
onEdit: () => void;
onVoid: () => void;
}
export default function PenaltyCardMenu({ onEdit, onVoid }: PenaltyCardMenuProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative">
<Button
variant="secondary"
className="p-2 w-8 h-8"
onClick={() => setIsOpen(!isOpen)}
>
<MoreVertical className="w-4 h-4" />
</Button>
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute right-0 mt-1 w-32 bg-deep-graphite border border-charcoal-outline rounded-lg shadow-lg z-20">
<button
onClick={() => {
onEdit();
setIsOpen(false);
}}
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-iron-gray/50 flex items-center gap-2"
>
<Edit className="w-4 h-4" />
Edit
</button>
<button
onClick={() => {
onVoid();
setIsOpen(false);
}}
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-iron-gray/50 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Void
</button>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
import { Plus } from 'lucide-react';
import Button from '../ui/Button';
interface PenaltyFABProps {
onClick: () => void;
}
export default function PenaltyFAB({ onClick }: PenaltyFABProps) {
return (
<div className="fixed bottom-6 right-6 z-50">
<Button
variant="primary"
className="w-14 h-14 rounded-full shadow-lg"
onClick={onClick}
title="Add Penalty"
>
<Plus className="w-6 h-6" />
</Button>
</div>
);
}

View File

@@ -3,14 +3,21 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { getQuickPenaltyUseCase } from '@/lib/di-container'; import { getQuickPenaltyUseCase } from '@/lib/di-container';
import type { Driver } from '@gridpilot/racing/application';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react'; import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';
interface DriverOption {
id: string;
name: string;
}
interface QuickPenaltyModalProps { interface QuickPenaltyModalProps {
raceId: string; raceId?: string;
drivers: Driver[]; drivers: DriverOption[];
onClose: () => void; onClose: () => void;
preSelectedDriver?: DriverOption;
adminId: string;
races?: { id: string; track: string; scheduledAt: Date }[];
} }
const INFRACTION_TYPES = [ const INFRACTION_TYPES = [
@@ -28,8 +35,9 @@ const SEVERITY_LEVELS = [
{ value: 'severe', label: 'Severe', description: 'Heavy penalty' }, { value: 'severe', label: 'Severe', description: 'Heavy penalty' },
] as const; ] as const;
export default function QuickPenaltyModal({ raceId, drivers, onClose }: QuickPenaltyModalProps) { export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelectedDriver, adminId, races }: QuickPenaltyModalProps) {
const [selectedDriver, setSelectedDriver] = useState<string>(''); const [selectedRaceId, setSelectedRaceId] = useState<string>(raceId || '');
const [selectedDriver, setSelectedDriver] = useState<string>(preSelectedDriver?.id || '');
const [infractionType, setInfractionType] = useState<string>(''); const [infractionType, setInfractionType] = useState<string>('');
const [severity, setSeverity] = useState<string>(''); const [severity, setSeverity] = useState<string>('');
const [notes, setNotes] = useState<string>(''); const [notes, setNotes] = useState<string>('');
@@ -39,21 +47,24 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose }: QuickPen
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!selectedDriver || !infractionType || !severity) return; if (!selectedRaceId || !selectedDriver || !infractionType || !severity) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const useCase = getQuickPenaltyUseCase(); const useCase = getQuickPenaltyUseCase();
await useCase.execute({ const command: any = {
raceId, raceId: selectedRaceId,
driverId: selectedDriver, driverId: selectedDriver,
adminId: 'driver-1', // TODO: Get from current user context adminId,
infractionType: infractionType as any, infractionType: infractionType as any,
severity: severity as any, severity: severity as any,
notes: notes.trim() || undefined, };
}); if (notes.trim()) {
command.notes = notes.trim();
}
await useCase.execute(command);
// Refresh the page to show updated results // Refresh the page to show updated results
router.refresh(); router.refresh();
@@ -72,24 +83,52 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose }: QuickPen
<h2 className="text-xl font-bold text-white mb-4">Quick Penalty</h2> <h2 className="text-xl font-bold text-white mb-4">Quick Penalty</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Race Selection */}
{races && !raceId && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Race
</label>
<select
value={selectedRaceId}
onChange={(e) => setSelectedRaceId(e.target.value)}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:border-primary-blue focus:outline-none"
required
>
<option value="">Select race...</option>
{races.map((race) => (
<option key={race.id} value={race.id}>
{race.track} ({race.scheduledAt.toLocaleDateString()})
</option>
))}
</select>
</div>
)}
{/* Driver Selection */} {/* Driver Selection */}
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">
Driver Driver
</label> </label>
<select {preSelectedDriver ? (
value={selectedDriver} <div className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white">
onChange={(e) => setSelectedDriver(e.target.value)} {preSelectedDriver.name}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:border-primary-blue focus:outline-none" </div>
required ) : (
> <select
<option value="">Select driver...</option> value={selectedDriver}
{drivers.map((driver) => ( onChange={(e) => setSelectedDriver(e.target.value)}
<option key={driver.id} value={driver.id}> className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:border-primary-blue focus:outline-none"
{driver.name} required
</option> >
))} <option value="">Select driver...</option>
</select> {drivers.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name}
</option>
))}
</select>
)}
</div> </div>
{/* Infraction Type */} {/* Infraction Type */}
@@ -175,7 +214,7 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose }: QuickPen
type="submit" type="submit"
variant="primary" variant="primary"
className="flex-1" className="flex-1"
disabled={loading || !selectedDriver || !infractionType || !severity} disabled={loading || !selectedRaceId || !selectedDriver || !infractionType || !severity}
> >
{loading ? 'Applying...' : 'Apply Penalty'} {loading ? 'Applying...' : 'Apply Penalty'}
</Button> </Button>

View File

@@ -0,0 +1,41 @@
'use client';
import React from 'react';
import { AlertTriangle } from 'lucide-react';
import Button from '../ui/Button';
interface DriverDTO {
id: string;
name: string;
}
interface InlinePenaltyButtonProps {
driver: DriverDTO;
onPenaltyClick?: (driver: DriverDTO) => void;
isAdmin: boolean;
}
export default function InlinePenaltyButton({
driver,
onPenaltyClick,
isAdmin,
}: InlinePenaltyButtonProps) {
if (!isAdmin) return null;
const handleButtonClick = () => {
if (onPenaltyClick) {
onPenaltyClick(driver);
}
};
return (
<Button
variant="danger"
className="p-1.5 min-h-[32px] w-8 h-8 rounded-full flex items-center justify-center"
onClick={handleButtonClick}
title={`Issue penalty to ${driver.name}`}
>
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
</Button>
);
}

View File

@@ -2,6 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { AlertTriangle, ExternalLink } from 'lucide-react'; import { AlertTriangle, ExternalLink } from 'lucide-react';
import InlinePenaltyButton from './InlinePenaltyButton';
type PenaltyTypeDTO = type PenaltyTypeDTO =
| 'time_penalty' | 'time_penalty'
@@ -41,6 +42,8 @@ interface ResultsTableProps {
fastestLapTime?: number | undefined; fastestLapTime?: number | undefined;
penalties?: PenaltyData[]; penalties?: PenaltyData[];
currentDriverId?: string | undefined; currentDriverId?: string | undefined;
isAdmin?: boolean;
onPenaltyClick?: (driver: DriverDTO) => void;
} }
export default function ResultsTable({ export default function ResultsTable({
@@ -50,6 +53,8 @@ export default function ResultsTable({
fastestLapTime, fastestLapTime,
penalties = [], penalties = [],
currentDriverId, currentDriverId,
isAdmin = false,
onPenaltyClick,
}: ResultsTableProps) { }: ResultsTableProps) {
const getDriver = (driverId: string): DriverDTO | undefined => { const getDriver = (driverId: string): DriverDTO | undefined => {
return drivers.find((d) => d.id === driverId); return drivers.find((d) => d.id === driverId);
@@ -118,6 +123,7 @@ export default function ResultsTable({
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Points</th> <th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Points</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">+/-</th> <th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">+/-</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Penalties</th> <th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Penalties</th>
{isAdmin && <th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Actions</th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -246,6 +252,17 @@ export default function ResultsTable({
<span className="text-gray-500"></span> <span className="text-gray-500"></span>
)} )}
</td> </td>
{isAdmin && (
<td className="py-3 px-4">
{driver && onPenaltyClick && (
<InlinePenaltyButton
driver={driver}
onPenaltyClick={onPenaltyClick}
isAdmin={isAdmin}
/>
)}
</td>
)}
</tr> </tr>
); );
})} })}

View File

@@ -103,6 +103,7 @@ import {
FileProtestUseCase, FileProtestUseCase,
ReviewProtestUseCase, ReviewProtestUseCase,
ApplyPenaltyUseCase, ApplyPenaltyUseCase,
QuickPenaltyUseCase,
RequestProtestDefenseUseCase, RequestProtestDefenseUseCase,
SubmitProtestDefenseUseCase, SubmitProtestDefenseUseCase,
GetSponsorDashboardUseCase, GetSponsorDashboardUseCase,
@@ -988,6 +989,15 @@ export function configureDIContainer(): void {
) )
); );
container.registerInstance(
DI_TOKENS.QuickPenaltyUseCase,
new QuickPenaltyUseCase(
penaltyRepository,
raceRepository,
leagueMembershipRepository
)
);
container.registerInstance( container.registerInstance(
DI_TOKENS.RequestProtestDefenseUseCase, DI_TOKENS.RequestProtestDefenseUseCase,
new RequestProtestDefenseUseCase(protestRepository, raceRepository, leagueMembershipRepository) new RequestProtestDefenseUseCase(protestRepository, raceRepository, leagueMembershipRepository)
@@ -1182,6 +1192,7 @@ export function configureDIContainer(): void {
raceRepository, raceRepository,
leagueRepository, leagueRepository,
resultRepository, resultRepository,
driverRepository,
standingRepository, standingRepository,
importRaceResultsPresenter importRaceResultsPresenter
) )

View File

@@ -57,6 +57,7 @@ import type {
FileProtestUseCase, FileProtestUseCase,
ReviewProtestUseCase, ReviewProtestUseCase,
ApplyPenaltyUseCase, ApplyPenaltyUseCase,
QuickPenaltyUseCase,
RequestProtestDefenseUseCase, RequestProtestDefenseUseCase,
SubmitProtestDefenseUseCase, SubmitProtestDefenseUseCase,
GetSponsorDashboardUseCase, GetSponsorDashboardUseCase,
@@ -490,6 +491,11 @@ class DIContainer {
return getDIContainer().resolve<ApplyPenaltyUseCase>(DI_TOKENS.ApplyPenaltyUseCase); return getDIContainer().resolve<ApplyPenaltyUseCase>(DI_TOKENS.ApplyPenaltyUseCase);
} }
get quickPenaltyUseCase(): QuickPenaltyUseCase {
this.ensureInitialized();
return getDIContainer().resolve<QuickPenaltyUseCase>(DI_TOKENS.QuickPenaltyUseCase);
}
get getRaceProtestsUseCase(): GetRaceProtestsUseCase { get getRaceProtestsUseCase(): GetRaceProtestsUseCase {
this.ensureInitialized(); this.ensureInitialized();
return getDIContainer().resolve<GetRaceProtestsUseCase>(DI_TOKENS.GetRaceProtestsUseCase); return getDIContainer().resolve<GetRaceProtestsUseCase>(DI_TOKENS.GetRaceProtestsUseCase);
@@ -871,6 +877,10 @@ export function getApplyPenaltyUseCase(): ApplyPenaltyUseCase {
return DIContainer.getInstance().applyPenaltyUseCase; return DIContainer.getInstance().applyPenaltyUseCase;
} }
export function getQuickPenaltyUseCase(): QuickPenaltyUseCase {
return DIContainer.getInstance().quickPenaltyUseCase;
}
export function getGetRaceProtestsUseCase(): GetRaceProtestsUseCase { export function getGetRaceProtestsUseCase(): GetRaceProtestsUseCase {
return DIContainer.getInstance().getRaceProtestsUseCase; return DIContainer.getInstance().getRaceProtestsUseCase;
} }

View File

@@ -63,6 +63,7 @@ export const DI_TOKENS = {
FileProtestUseCase: Symbol.for('FileProtestUseCase'), FileProtestUseCase: Symbol.for('FileProtestUseCase'),
ReviewProtestUseCase: Symbol.for('ReviewProtestUseCase'), ReviewProtestUseCase: Symbol.for('ReviewProtestUseCase'),
ApplyPenaltyUseCase: Symbol.for('ApplyPenaltyUseCase'), ApplyPenaltyUseCase: Symbol.for('ApplyPenaltyUseCase'),
QuickPenaltyUseCase: Symbol.for('QuickPenaltyUseCase'),
RequestProtestDefenseUseCase: Symbol.for('RequestProtestDefenseUseCase'), RequestProtestDefenseUseCase: Symbol.for('RequestProtestDefenseUseCase'),
SubmitProtestDefenseUseCase: Symbol.for('SubmitProtestDefenseUseCase'), SubmitProtestDefenseUseCase: Symbol.for('SubmitProtestDefenseUseCase'),

View File

@@ -1,229 +1,141 @@
# GridPilot Theme — “Smooth Performance Dark” # GridPilot Theme — “Motorsport Infrastructure, Smoothly Engineered”
*A modern, ultra-polished, buttery-smooth interface that feels engineered, premium, and joyful — without losing the seriousness of sim racing.*
*A precise, professional motorsport interface with premium smoothness — engineered for trust, control, and long-term use.*
--- ---
# 1. Design Philosophy ## 1. Design Philosophy
GridPilot should feel like: GridPilot should feel like:
- **a precision instrument**, not a toy - **race control software**, not a game UI
- **a premium dashboard**, not a corporate SaaS page - **infrastructure**, not a startup product
- **smooth and responsive**, not flashy - **engineered**, not styled
- **crafted**, not overdesigned - **stable and authoritative**, yet pleasant to use
- **racing-inspired**, not gamer-edgy - **built for years**, not trends
It combines: The goal is not excitement.
- the readability & seriousness of motorsport tools The goal is **confidence**.
- with the soft, fluid, polished feel of a high-end app
Think: Think:
**"iRacing x Apple UI x Motorsport telemetry aesthetics"**. **“FIA race control x timing screens x modern tooling — with smooth interaction.”**
--- ---
# 2. Visual Style ## 2. Visual Style
### Core Aesthetic: ### Core Aesthetic:
- dark, matte background - dark, neutral background
- soft gradients (subtle, not neon) - minimal gradients (almost invisible)
- elegant glows only where needed - restrained highlights only for meaning
- crisp typography - strict hierarchy
- generous spacing - dense, readable layouts
- smooth UI hierarchy transitions - smooth transitions only where state changes
- layer depth through blur + shadow stacking (but tasteful)
### Color Palette: Everything should look:
- **Deep Graphite:** `#0E0F11` (main background) **intentional, measured, and calm.**
- **Iron Gray:** `#181B1F` (cards & panels)
- **Charcoal Outline:** `#22262A` (borders)
- **Primary Blue:** `#198CFF` (accents, active states)
- **Performance Green:** `#6FE37A` (success)
- **Warning Amber:** `#FFC556` (markers)
- **Subtle Neon Aqua:** `#43C9E6` (interactive glow effects)
Colors are **precise**, not noisy.
--- ---
# 3. Animation Philosophy — “Buttery Smooth, Never Distracting” ### Color Palette (refined)
Animations in GridPilot should: - **Graphite Black:** `#0E0F11`
- feel like a **fast steering rack**: sharp + controlled - **Panel Gray:** `#171A1E`
- feel **premium**, not “flashy” - **Border Gray:** `#22262A`
- be **motivated**, not ornamental - **Primary Accent:** `#198CFF` (used sparingly)
- communicate **state change** clearly - **Success Green:** `#6FE37A`
- **Warning Amber:** `#FFC556`
- **Critical Red:** `#E35C5C`
**Animation Style:** No neon.
- low-spring, high-damping motion No playful colors.
- small distances, high velocity Color = meaning, not decoration.
- micro-easing, Apple-like rebound
- intelligent inertia
- zero stutter
**Target vibe:**
> “Everything feels alive and responsive, like the UI wants to race with you.”
--- ---
# 4. Where Animations Should Shine ## 3. Motion & Interaction
### ✔ Hover Interactions ### Animation Philosophy
Buttons + cards get: Motion exists only to:
- subtle upscale (`1.0 → 1.03`) - confirm an action
- color bloom - show hierarchy
- ambient glow (low opacity, soft spread) - indicate state change
### ✔ Page Transitions Never to impress.
- fade + slide (3050px)
- layered parallax for content panels
- 150250ms total
Feels warm, inviting, non-static. ### Characteristics:
- short durations (120200ms)
- low amplitude
- no exaggerated easing
- no elastic bounces
- no decorative movement
### ✔ Filters & Tabs **Motion should feel like a well-damped suspension — not a show car.**
- sliding underline indicator
- smooth kinetic scrolling
- minimal ripple or highlight
### ✔ Dialogs & Panels
- spring pop (`scale 0.96 → 1`)
- soft drop shadow expansion
- background blur fade-in
### ✔ Table Row Expand / Collapse
- height transition: 150ms
- opacity fade-in: 120ms
- chevron rotation: 180ms
Feels like unfolding technical data — perfect for racing nerds.
### ✔ Notifications
- slide-in from top right
- friction-based deceleration
- micro-bounce at rest state
--- ---
# 5. What NOT to animate ## 4. Where Motion Is Allowed
To avoid becoming “too modern = startup SaaS = untrustworthy”: - button press feedback
- panel open / close
- table row expansion
- state changes (pending → approved → completed)
- subtle loading indicators
**No:** If motion does not improve clarity → remove it.
- giant hero animations
- unnecessary motion in typography ---
- floating shapes / illustration wobble
- confetti / particle effects ## 5. What We Explicitly Avoid
- autoplay video backgrounds
- mobile-app style “over cute” transitions - hero animations
- animated backgrounds
- glowing UI chrome
- playful hover gimmicks
- “app store” aesthetics
- anything that reduces trust
GridPilot must feel: GridPilot must feel:
**professional → premium → but still understated.** **reliable before it feels beautiful.**
--- ---
# 6. Component Design Rules ## 6. Components
### Cards
- slightly rounded (68px)
- soft shadow (blur 2028px)
- subtle ambient noise texture (optional)
- gentle hover glow
### Buttons
- pill shape (but not too round)
- glossy gradient *only when hovered*
- laser-sharp outline on active state
- fast press down animation (`75ms`)
### Tables ### Tables
- high-density, readable - primary UI element
- animated sort indicators - dense, readable
- fade-in rows on update - fixed column logic
- highlight row on hover - no playful effects
### Cards
- functional grouping
- no visual dominance
- secondary to tables
### Modals ### Modals
- glassy blurred background - simple
- smooth opening - fast
- soft drop-shadow bloom - decisive
- quick responsive closing
--- ---
# 7. Typography ## 7. Typography
A modern, premium sans-serif: - neutral sans-serif
- **Inter** - excellent numeric readability
- **Roboto Flex** - no personality fonts
- or **Plus Jakarta Sans**
Font weight: Primary goal:
- light + regular for body **information clarity, not brand expression.**
- semibold for headings
- numeric fields medium or monospaced (for racing aesthetics)
Typography motion:
- heading fade-in
- numeric counters animate upward subtly (60120ms)
--- ---
# 8. UX Tone ## 8. Design Principle Summary
GridPilot should **feel**: GridPilot is:
- not gamer UI
- not esports branding
- not corporate SaaS
- confident It is:
- calm **modern motorsport infrastructure software.**
- minimal
- smart
- “built by people who actually race”
- respectful of the users time
- not corporate
- not recruiter-slick
- not childish gamer UI
But also:
**pleasant, smooth, and delightful to interact with.**
---
# 9. Comparative Inspirations
### From iRacing UI:
- dark palette
- density
- data-first layout
- serious tone
### From VRS:
- technical clarity
- motorsport professionalism
### From Apple UI:
- smooth transitions
- subtle bounce
- soft shadows
- tasteful blur
### From SimHub / Racelab:
- functional panels
- high readability
- non-intrusive visuals
GridPilot combines all these influences without looking like any of them directly.
---
# 10. Goal Summary
**GridPilot = the “luxury cockpit dashboard” of league racing platforms.**
- dark, technical look
- premium smoothness
- fast, precise interactions
- functional layouts
- no corporate noise
- no SaaS gimmicks
- no over-the-top neon gamer aesthetic
Just clean, fast, beautiful racing software
that feels as nice to use as a fresh lap in a good rhythm.

View File

@@ -1,197 +1,123 @@
# GridPilot — Voice & Tone Guide # GridPilot — Voice & Tone Guide
*A calm, clear, confident voice built for sim racers.*
*A calm, neutral, authoritative voice for motorsport infrastructure.*
--- ---
## 1. Core Personality ## 1. Core Personality
GridPilots voice is: GridPilot is:
- calm
- neutral
- precise
- experienced
- understated
### **Calm** We do not excite.
Never loud, never chaotic, never dramatic. We inform.
### **Clear**
Short sentences. Direct meaning. Zero fluff.
### **Competent**
We sound like people who know racing and know what matters.
### **Friendly, not goofy**
Approachable and human — without memes, slang, or cringe.
### **Non-corporate**
We avoid startup jargon and “enterprise” tone completely.
--- ---
## 2. How GridPilot Sounds ## 2. How GridPilot Sounds
### **Direct** ### Clear
We make the point quickly. No fillers. Short sentences.
No ambiguity.
**Example:** ### Neutral
“Standings updated.” No opinion.
No hype.
No exaggeration.
### **Minimal** ### Competent
We remove unnecessary words. We assume the user understands racing.
Writing should feel clean — like the UI.
### **Honest** ### Professional
We never overpromise or exaggerate. We sound like race officials, not marketers.
GridPilot says what it does, nothing more.
### **Human**
Natural phrasing, like talking to another racer.
--- ---
## 3. What We Avoid ## 3. What We Avoid
### ❌ Corporate language - marketing language
“scalable solution,” “empower,” “revolutionize,” “synergy” - motivational phrasing
- emotional exaggeration
- jokes
- slang
- buzzwords
- storytelling tone
### ❌ Startup hype GridPilot is not here to entertain.
“game-changing,” “disruptive,” “next-gen” It is here to **operate**.
### ❌ Gamer slang
“lets gooo,” “pog,” “EZ clap,” emojis everywhere
### ❌ Sales pressure
“Sign up now!!! Limited time only!!!”
### ❌ Over-explaining
No long paragraphs to say something simple.
--- ---
## 4. Tone by Context ## 4. Tone by Context
### **Landing Page** ### UI
- calm - direct
- confident - factual
- benefit-focused
- no hype
- welcoming
Example:
“League racing should feel organized. GridPilot brings everything into one place.”
---
### **In-App UI**
- crisp
- tool-like - tool-like
Examples:
- “Season created.”
- “Driver approved.”
- “Penalty applied.”
---
### Errors
- neutral - neutral
- small sentences - calm
Examples:
“Race added.”
“Results imported.”
“Penalty applied.”
---
### **Notifications**
- non-intrusive
- soft
- clear
Examples:
“Your next race is tomorrow.”
“Standings updated.”
---
### **Errors**
- helpful
- steady
- no blame - no blame
Examples: Examples:
“Something went wrong. Try again.” - “Action failed.”
“Connection lost. Reconnecting… - “Invalid input.
- “Try again.”
--- ---
### **Emails** ### Notifications
- friendly
- simple
- short - short
- non-intrusive
Examples:
- “Race starts in 30 minutes.”
- “Results updated.”
---
### Emails
- factual
- brief
- no sales language
Example: Example:
“Your season starts next week. Heres the schedule.” “Your season schedule has been updated.”
--- ---
## 5. Writing Style Rules ## 5. Writing Rules
### **Short sentences** - remove unnecessary adjectives
Easy to read. Easy to scan. - use active voice
- one idea per sentence
### **Simple verbs** - no filler
Join, manage, view, race, update. - no persuasion
### **Active voice**
“GridPilot updates your standings.”
Not:
“Your standings are being updated…”
### **No marketing padding**
We never pretend to be bigger than we are.
### **Every sentence should have purpose**
No filler words. No decorative language.
--- ---
## 6. Phrases We Like ## 6. Phrases We Prefer
-Everything in one place.” -All data in one place.”
-Your racing identity.” -Clear standings.”
-Clean standings.” -Structured seasons.”
- “Consistent rules.”
- “No spreadsheets.” - “No spreadsheets.”
- “Race. We handle the rest.”
- “Built for league racing.”
- “Clear. Simple. Consistent.”
These reinforce clarity and confidence.
--- ---
## 7. Phrases We Never Use ## 7. One-line Voice Summary
- “premium experience” **GridPilot speaks like race control: calm, precise, and trustworthy.**
- “unlock your potential”
- “cutting-edge AI”
- “transform the sim racing landscape”
- “were disrupting the industry”
- “epic,” “insane,” “crazy good,” etc.
Anything salesy, dramatic, childish, or corporate is banned.
---
## 8. Emotional Goals
GridPilot should make people feel:
### **In control**
Information is clear and predictable.
### **Supported**
Admin work is easier. Driver info is organized.
### **Respected**
We never talk down to users.
### **Focused**
The tone keeps attention on racing, not us.
### **Confident**
The platform feels stable and trustworthy.
---
## 9. One-line Voice Summary
**GridPilot speaks like a calm, competent racer who explains things clearly — never loud, never corporate, never cringe.**

View File

@@ -29,6 +29,7 @@ export * from './use-cases/GetLeagueStatsUseCase';
export * from './use-cases/FileProtestUseCase'; export * from './use-cases/FileProtestUseCase';
export * from './use-cases/ReviewProtestUseCase'; export * from './use-cases/ReviewProtestUseCase';
export * from './use-cases/ApplyPenaltyUseCase'; export * from './use-cases/ApplyPenaltyUseCase';
export * from './use-cases/QuickPenaltyUseCase';
export * from './use-cases/GetRaceProtestsUseCase'; export * from './use-cases/GetRaceProtestsUseCase';
export * from './use-cases/GetRacePenaltiesUseCase'; export * from './use-cases/GetRacePenaltiesUseCase';
export * from './use-cases/RequestProtestDefenseUseCase'; export * from './use-cases/RequestProtestDefenseUseCase';

View File

@@ -1,6 +1,7 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import { Result } from '../../domain/entities/Result'; import { Result } from '../../domain/entities/Result';
import type { AsyncUseCase } from '@gridpilot/shared/application'; import type { AsyncUseCase } from '@gridpilot/shared/application';
@@ -35,6 +36,7 @@ export class ImportRaceResultsUseCase
private readonly raceRepository: IRaceRepository, private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository, private readonly leagueRepository: ILeagueRepository,
private readonly resultRepository: IResultRepository, private readonly resultRepository: IResultRepository,
private readonly driverRepository: IDriverRepository,
private readonly standingRepository: IStandingRepository, private readonly standingRepository: IStandingRepository,
public readonly presenter: IImportRaceResultsPresenter, public readonly presenter: IImportRaceResultsPresenter,
) {} ) {}
@@ -57,15 +59,22 @@ export class ImportRaceResultsUseCase
throw new BusinessRuleViolationError('Results already exist for this race'); throw new BusinessRuleViolationError('Results already exist for this race');
} }
const entities = results.map((dto) => // Lookup drivers by iracingId and create results with driver.id
Result.create({ const entities = await Promise.all(
id: dto.id, results.map(async (dto) => {
raceId: dto.raceId, const driver = await this.driverRepository.findByIRacingId(dto.driverId);
driverId: dto.driverId, if (!driver) {
position: dto.position, throw new BusinessRuleViolationError(`Driver with iRacing ID ${dto.driverId} not found`);
fastestLap: dto.fastestLap, }
incidents: dto.incidents, return Result.create({
startPosition: dto.startPosition, id: dto.id,
raceId: dto.raceId,
driverId: driver.id,
position: dto.position,
fastestLap: dto.fastestLap,
incidents: dto.incidents,
startPosition: dto.startPosition,
});
}), }),
); );

BIN
race-results-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -285,10 +285,31 @@ describe('ImportRaceResultsUseCase', () => {
const presenter = new FakeImportRaceResultsPresenter(); const presenter = new FakeImportRaceResultsPresenter();
const driverRepository = {
findById: async (): Promise<Driver | null> => null,
findByIRacingId: async (iracingId: string): Promise<Driver | null> => {
// Mock finding driver by iracingId
if (iracingId === 'driver-1') {
return Driver.create({ id: 'driver-1', iracingId: 'driver-1', name: 'Driver One', country: 'US' });
}
if (iracingId === 'driver-2') {
return Driver.create({ id: 'driver-2', iracingId: 'driver-2', name: 'Driver Two', country: 'GB' });
}
return null;
},
findAll: async (): Promise<Driver[]> => [],
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByIRacingId: async (): Promise<boolean> => false,
};
const useCase = new ImportRaceResultsUseCase( const useCase = new ImportRaceResultsUseCase(
raceRepository, raceRepository,
leagueRepository, leagueRepository,
resultRepository, resultRepository,
driverRepository,
standingRepository, standingRepository,
presenter, presenter,
); );

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
// Mock() Button component
vi.mock('../../../apps/website/components/ui/Button', () => ({
default: ({ onClick, children, className, title, variant }: any) => (
<button
onClick={onClick}
className={className}
title={title}
data-variant={variant}
data-testid="penalty-button"
>
{children}
</button>
),
}));
import InlinePenaltyButton from '../../../apps/website/components/races/InlinePenaltyButton';
describe('InlinePenaltyButton', () => {
const mockDriver = { id: 'driver-1', name: 'Test Driver' };
const mockOnPenaltyClick = vi.fn();
it('should not render when user is not admin', () => {
render(
<InlinePenaltyButton
driver={mockDriver}
onPenaltyClick={mockOnPenaltyClick}
isAdmin={false}
/>
);
const button = screen.queryByTestId('penalty-button');
expect(button).not.toBeInTheDocument();
});
it('should render when user is admin', () => {
render(
<InlinePenaltyButton
driver={mockDriver}
onPenaltyClick={mockOnPenaltyClick}
isAdmin={true}
/>
);
const button = screen.getByTestId('penalty-button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('title', 'Issue penalty to Test Driver');
expect(button).toHaveAttribute('data-variant', 'danger');
});
it('should call onPenaltyClick when button is clicked', () => {
render(
<InlinePenaltyButton
driver={mockDriver}
onPenaltyClick={mockOnPenaltyClick}
isAdmin={true}
/>
);
const button = screen.getByTestId('penalty-button');
fireEvent.click(button);
expect(mockOnPenaltyClick).toHaveBeenCalledTimes(1);
expect(mockOnPenaltyClick).toHaveBeenCalledWith(mockDriver);
});
it('should not crash when onPenaltyClick is not provided', () => {
render(
<InlinePenaltyButton
driver={mockDriver}
isAdmin={true}
/>
);
const button = screen.getByTestId('penalty-button');
// Should not crash when clicked without onPenaltyClick
expect(() => fireEvent.click(button)).not.toThrow();
});
it('should have proper button styling for spacing', () => {
render(
<InlinePenaltyButton
driver={mockDriver}
onPenaltyClick={mockOnPenaltyClick}
isAdmin={true}
/>
);
const button = screen.getByTestId('penalty-button');
// Check that button has proper spacing classes
expect(button).toHaveClass('p-1.5');
expect(button).toHaveClass('min-h-[32px]');
expect(button).toHaveClass('w-8');
expect(button).toHaveClass('h-8');
expect(button).toHaveClass('rounded-full');
expect(button).toHaveClass('flex');
expect(button).toHaveClass('items-center');
expect(button).toHaveClass('justify-center');
});
it('should render AlertTriangle icon with proper sizing', () => {
render(
<InlinePenaltyButton
driver={mockDriver}
onPenaltyClick={mockOnPenaltyClick}
isAdmin={true}
/>
);
const button = screen.getByTestId('penalty-button');
const icon = button.querySelector('svg');
expect(icon).toBeInTheDocument();
expect(icon).toHaveClass('w-4');
expect(icon).toHaveClass('h-4');
expect(icon).toHaveClass('flex-shrink-0');
});
});