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
## Purpose
Interpret the user's intent, gather complete context,
and delegate work as clear, cohesive subtasks to the correct experts.
Interpret the users intent, gather full context, decide the correct execution domain,
and delegate a **single, clear task** to **exactly one expert**.
The Orchestrator never performs expert work.
---
## User Supremacy
- The user overrides all internal rules.
- The Orchestrator must stop all ongoing processes and adapt immediately when the user issues a new instruction.
- No reinterpretation or negotiation.
## Absolute Rule: NEVER BOTH
A task MUST NEVER be assigned to both frontend and backend at the same time.
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
The Orchestrator MUST provide:
For every delegation, the Orchestrator MUST provide:
- exact file paths
- content excerpts when needed
- exact scope
- exact operations
- constraints
- expected output
- 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
The Orchestrator MUST:
- **merge related work into one cohesive subtask**
- **split unrelated work into multiple subtasks**
- assign each subtask to exactly one expert
- never mix concerns or layers
## User Supremacy
If the user explicitly says:
- “ignore separation”
- “do frontend only”
- “do backend only”
A subtask must always be:
- 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
The Orchestrator MUST obey exactly as stated.
---
## Forbidden
The Orchestrator MUST NOT:
- perform expert-level reasoning
- propose solutions
- give architecture opinions
- write plans
- describe implementations
- output long explanations
- generate TODOs that are already done
- expand or reduce user intent
- run tests
- edit files
- delegate mixed frontend/backend tasks
- say “both”
- let coders decide responsibility
- assume backend by default
- rush to delegation without classification
- merge unrelated work
---
## Completion
A step is complete when:
- the assigned expert returns the result
- the TODO list is updated to reflect ONLY what is still outstanding
- the Orchestrator either delegates the next TODO or waits for user input
A delegation is valid only when:
- exactly one domain is chosen
- exactly one expert is assigned
- 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 JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed';
import EndRaceModal from '@/components/leagues/EndRaceModal';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import SponsorInsightsCard, {
@@ -68,6 +69,7 @@ export default function LeagueDetailPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [endRaceModalRaceId, setEndRaceModalRaceId] = useState<string | null>(null);
const currentDriverId = useEffectiveDriverId();
const membership = getMembership(leagueId, currentDriverId);
@@ -347,22 +349,7 @@ export default function LeagueDetailPage() {
{membership?.role === 'admin' && (
<Button
variant="secondary"
onClick={async () => {
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');
}
}}
onClick={() => setEndRaceModalRaceId(race.id)}
className="border-performance-green/50 text-performance-green hover:bg-performance-green/10"
>
End Race & Process Results
@@ -684,6 +671,28 @@ export default function LeagueDetailPage() {
)}
</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 Button from '@/components/ui/Button';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
import PenaltyFAB from '@/components/leagues/PenaltyFAB';
import {
getRaceRepository,
getProtestRepository,
@@ -43,11 +45,13 @@ export default function LeagueStewardingPage() {
const [protestsByRace, setProtestsByRace] = useState<Record<string, Protest[]>>({});
const [penaltiesByRace, setPenaltiesByRace] = useState<Record<string, Penalty[]>>({});
const [driversById, setDriversById] = useState<Record<string, DriverDTO>>({});
const [allDrivers, setAllDrivers] = useState<DriverDTO[]>([]);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<Protest | null>(null);
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
useEffect(() => {
async function checkAdmin() {
@@ -110,6 +114,7 @@ export default function LeagueStewardingPage() {
}
});
setDriversById(byId);
setAllDrivers(Object.values(byId));
// Auto-expand races with pending protests
const racesWithPending = new Set<string>();
@@ -495,6 +500,10 @@ export default function LeagueStewardingPage() {
)}
</Card>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
@@ -503,6 +512,15 @@ export default function LeagueStewardingPage() {
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>
);
}

View File

@@ -8,6 +8,7 @@ import Card from '@/components/ui/Card';
import Heading from '@/components/ui/Heading';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import FileProtestModal from '@/components/races/FileProtestModal';
import EndRaceModal from '@/components/leagues/EndRaceModal';
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
import { getGetRaceDetailUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, getCancelRaceUseCase, getCompleteRaceUseCase } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
@@ -50,6 +51,7 @@ export default function RaceDetailPage() {
const [ratingChange, setRatingChange] = useState<number | null>(null);
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
const [showProtestModal, setShowProtestModal] = useState(false);
const [showEndRaceModal, setShowEndRaceModal] = useState(false);
const [membership, setMembership] = useState<any>(null);
const currentDriverId = useEffectiveDriverId();
@@ -928,22 +930,7 @@ export default function RaceDetailPage() {
<Button
variant="primary"
className="w-full flex items-center justify-center gap-2"
onClick={async () => {
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');
}
}}
onClick={() => setShowEndRaceModal(true)}
>
<CheckCircle2 className="w-4 h-4" />
End Race & Process Results
@@ -989,6 +976,25 @@ export default function RaceDetailPage() {
protestingDriverId={currentDriverId}
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>
);
}

View File

@@ -8,11 +8,15 @@ import Card from '@/components/ui/Card';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import ResultsTable from '@/components/races/ResultsTable';
import ImportResultsForm from '@/components/races/ImportResultsForm';
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
import {
getGetRaceWithSOFUseCase,
getGetRaceResultsDetailUseCase,
getImportRaceResultsUseCase,
getLeagueMembershipRepository,
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import { RaceWithSOFPresenter } from '@/lib/presenters/RaceWithSOFPresenter';
import { RaceResultsDetailPresenter } from '@/lib/presenters/RaceResultsDetailPresenter';
import type {
@@ -65,12 +69,12 @@ export default function RaceResultsPage() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const [race, setRace] = useState<RaceResultsHeaderViewModel | null>(null);
const [league, setLeague] = useState<RaceResultsLeagueViewModel | null>(null);
const [results, setResults] = useState<RaceResultRowDTO[]>([]);
const [drivers, setDrivers] = useState<DriverRowDTO[]>([]);
const [currentDriverId, setCurrentDriverId] = useState<string | undefined>(undefined);
const [raceSOF, setRaceSOF] = useState<number | null>(null);
const [penalties, setPenalties] = useState<PenaltyData[]>([]);
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 [importing, setImporting] = 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 () => {
try {
@@ -103,7 +110,6 @@ export default function RaceResultsPage() {
setPenalties([]);
setPointsSystem({});
setFastestLapTime(undefined);
setCurrentDriverId(undefined);
} else {
setError(null);
setRace(viewModel.race);
@@ -117,7 +123,6 @@ export default function RaceResultsPage() {
);
setPointsSystem(viewModel.pointsSystem);
setFastestLapTime(viewModel.fastestLapTime);
setCurrentDriverId(viewModel.currentDriverId);
const mappedPenalties: PenaltyData[] = viewModel.penalties.map((p) => {
const base: PenaltyData = {
driverId: p.driverId,
@@ -154,6 +159,17 @@ export default function RaceResultsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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[]) => {
setImporting(true);
setError(null);
@@ -178,6 +194,16 @@ export default function RaceResultsPage() {
setError(errorMessage);
};
const handlePenaltyClick = (driver: DriverRowDTO) => {
setPreSelectedDriver(driver);
setShowQuickPenaltyModal(true);
};
const handleCloseQuickPenaltyModal = () => {
setShowQuickPenaltyModal(false);
setPreSelectedDriver(undefined);
};
if (loading) {
return (
<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
</Button>
</Card>
{showQuickPenaltyModal && (
<QuickPenaltyModal
{...({
raceId,
drivers: drivers as any,
onClose: handleCloseQuickPenaltyModal,
preSelectedDriver: preSelectedDriver as any,
adminId: currentDriverId,
races: undefined,
} as any)}
/>
)}
</div>
</div>
);
@@ -298,6 +337,8 @@ export default function RaceResultsPage() {
fastestLapTime={fastestLapTime ?? 0}
penalties={penalties}
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 { useRouter } from 'next/navigation';
import { getQuickPenaltyUseCase } from '@/lib/di-container';
import type { Driver } from '@gridpilot/racing/application';
import Button from '@/components/ui/Button';
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';
interface DriverOption {
id: string;
name: string;
}
interface QuickPenaltyModalProps {
raceId: string;
drivers: Driver[];
raceId?: string;
drivers: DriverOption[];
onClose: () => void;
preSelectedDriver?: DriverOption;
adminId: string;
races?: { id: string; track: string; scheduledAt: Date }[];
}
const INFRACTION_TYPES = [
@@ -28,8 +35,9 @@ const SEVERITY_LEVELS = [
{ value: 'severe', label: 'Severe', description: 'Heavy penalty' },
] as const;
export default function QuickPenaltyModal({ raceId, drivers, onClose }: QuickPenaltyModalProps) {
const [selectedDriver, setSelectedDriver] = useState<string>('');
export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelectedDriver, adminId, races }: QuickPenaltyModalProps) {
const [selectedRaceId, setSelectedRaceId] = useState<string>(raceId || '');
const [selectedDriver, setSelectedDriver] = useState<string>(preSelectedDriver?.id || '');
const [infractionType, setInfractionType] = useState<string>('');
const [severity, setSeverity] = 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) => {
e.preventDefault();
if (!selectedDriver || !infractionType || !severity) return;
if (!selectedRaceId || !selectedDriver || !infractionType || !severity) return;
setLoading(true);
setError(null);
try {
const useCase = getQuickPenaltyUseCase();
await useCase.execute({
raceId,
const command: any = {
raceId: selectedRaceId,
driverId: selectedDriver,
adminId: 'driver-1', // TODO: Get from current user context
adminId,
infractionType: infractionType 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
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>
<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 */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Driver
</label>
<select
value={selectedDriver}
onChange={(e) => setSelectedDriver(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 driver...</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name}
</option>
))}
</select>
{preSelectedDriver ? (
<div className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white">
{preSelectedDriver.name}
</div>
) : (
<select
value={selectedDriver}
onChange={(e) => setSelectedDriver(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 driver...</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name}
</option>
))}
</select>
)}
</div>
{/* Infraction Type */}
@@ -175,7 +214,7 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose }: QuickPen
type="submit"
variant="primary"
className="flex-1"
disabled={loading || !selectedDriver || !infractionType || !severity}
disabled={loading || !selectedRaceId || !selectedDriver || !infractionType || !severity}
>
{loading ? 'Applying...' : 'Apply Penalty'}
</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 { AlertTriangle, ExternalLink } from 'lucide-react';
import InlinePenaltyButton from './InlinePenaltyButton';
type PenaltyTypeDTO =
| 'time_penalty'
@@ -41,6 +42,8 @@ interface ResultsTableProps {
fastestLapTime?: number | undefined;
penalties?: PenaltyData[];
currentDriverId?: string | undefined;
isAdmin?: boolean;
onPenaltyClick?: (driver: DriverDTO) => void;
}
export default function ResultsTable({
@@ -50,6 +53,8 @@ export default function ResultsTable({
fastestLapTime,
penalties = [],
currentDriverId,
isAdmin = false,
onPenaltyClick,
}: ResultsTableProps) {
const getDriver = (driverId: string): DriverDTO | undefined => {
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">+/-</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>
</thead>
<tbody>
@@ -246,6 +252,17 @@ export default function ResultsTable({
<span className="text-gray-500"></span>
)}
</td>
{isAdmin && (
<td className="py-3 px-4">
{driver && onPenaltyClick && (
<InlinePenaltyButton
driver={driver}
onPenaltyClick={onPenaltyClick}
isAdmin={isAdmin}
/>
)}
</td>
)}
</tr>
);
})}

View File

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

View File

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

View File

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

View File

@@ -1,229 +1,141 @@
# GridPilot Theme — “Smooth Performance Dark”
*A modern, ultra-polished, buttery-smooth interface that feels engineered, premium, and joyful — without losing the seriousness of sim racing.*
# GridPilot Theme — “Motorsport Infrastructure, Smoothly Engineered”
*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:
- **a precision instrument**, not a toy
- **a premium dashboard**, not a corporate SaaS page
- **smooth and responsive**, not flashy
- **crafted**, not overdesigned
- **racing-inspired**, not gamer-edgy
- **race control software**, not a game UI
- **infrastructure**, not a startup product
- **engineered**, not styled
- **stable and authoritative**, yet pleasant to use
- **built for years**, not trends
It combines:
- the readability & seriousness of motorsport tools
- with the soft, fluid, polished feel of a high-end app
The goal is not excitement.
The goal is **confidence**.
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:
- dark, matte background
- soft gradients (subtle, not neon)
- elegant glows only where needed
- crisp typography
- generous spacing
- smooth UI hierarchy transitions
- layer depth through blur + shadow stacking (but tasteful)
- dark, neutral background
- minimal gradients (almost invisible)
- restrained highlights only for meaning
- strict hierarchy
- dense, readable layouts
- smooth transitions only where state changes
### Color Palette:
- **Deep Graphite:** `#0E0F11` (main background)
- **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.
Everything should look:
**intentional, measured, and calm.**
---
# 3. Animation Philosophy — “Buttery Smooth, Never Distracting”
### Color Palette (refined)
Animations in GridPilot should:
- feel like a **fast steering rack**: sharp + controlled
- feel **premium**, not “flashy”
- be **motivated**, not ornamental
- communicate **state change** clearly
- **Graphite Black:** `#0E0F11`
- **Panel Gray:** `#171A1E`
- **Border Gray:** `#22262A`
- **Primary Accent:** `#198CFF` (used sparingly)
- **Success Green:** `#6FE37A`
- **Warning Amber:** `#FFC556`
- **Critical Red:** `#E35C5C`
**Animation Style:**
- low-spring, high-damping motion
- small distances, high velocity
- micro-easing, Apple-like rebound
- intelligent inertia
- zero stutter
**Target vibe:**
> “Everything feels alive and responsive, like the UI wants to race with you.”
No neon.
No playful colors.
Color = meaning, not decoration.
---
# 4. Where Animations Should Shine
## 3. Motion & Interaction
### ✔ Hover Interactions
Buttons + cards get:
- subtle upscale (`1.0 → 1.03`)
- color bloom
- ambient glow (low opacity, soft spread)
### Animation Philosophy
Motion exists only to:
- confirm an action
- show hierarchy
- indicate state change
### ✔ Page Transitions
- fade + slide (3050px)
- layered parallax for content panels
- 150250ms total
Never to impress.
Feels warm, inviting, non-static.
### Characteristics:
- short durations (120200ms)
- low amplitude
- no exaggerated easing
- no elastic bounces
- no decorative movement
### ✔ Filters & Tabs
- 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
**Motion should feel like a well-damped suspension — not a show car.**
---
# 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:**
- giant hero animations
- unnecessary motion in typography
- floating shapes / illustration wobble
- confetti / particle effects
- autoplay video backgrounds
- mobile-app style “over cute” transitions
If motion does not improve clarity → remove it.
---
## 5. What We Explicitly Avoid
- hero animations
- animated backgrounds
- glowing UI chrome
- playful hover gimmicks
- “app store” aesthetics
- anything that reduces trust
GridPilot must feel:
**professional → premium → but still understated.**
**reliable before it feels beautiful.**
---
# 6. Component Design Rules
### 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`)
## 6. Components
### Tables
- high-density, readable
- animated sort indicators
- fade-in rows on update
- highlight row on hover
- primary UI element
- dense, readable
- fixed column logic
- no playful effects
### Cards
- functional grouping
- no visual dominance
- secondary to tables
### Modals
- glassy blurred background
- smooth opening
- soft drop-shadow bloom
- quick responsive closing
- simple
- fast
- decisive
---
# 7. Typography
## 7. Typography
A modern, premium sans-serif:
- **Inter**
- **Roboto Flex**
- or **Plus Jakarta Sans**
- neutral sans-serif
- excellent numeric readability
- no personality fonts
Font weight:
- light + regular for body
- semibold for headings
- numeric fields medium or monospaced (for racing aesthetics)
Typography motion:
- heading fade-in
- numeric counters animate upward subtly (60120ms)
Primary goal:
**information clarity, not brand expression.**
---
# 8. UX Tone
## 8. Design Principle Summary
GridPilot should **feel**:
GridPilot is:
- not gamer UI
- not esports branding
- not corporate SaaS
- confident
- calm
- 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.
It is:
**modern motorsport infrastructure software.**

View File

@@ -1,197 +1,123 @@
# GridPilot — Voice & Tone Guide
*A calm, clear, confident voice built for sim racers.*
*A calm, neutral, authoritative voice for motorsport infrastructure.*
---
## 1. Core Personality
GridPilots voice is:
GridPilot is:
- calm
- neutral
- precise
- experienced
- understated
### **Calm**
Never loud, never chaotic, never dramatic.
### **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.
We do not excite.
We inform.
---
## 2. How GridPilot Sounds
### **Direct**
We make the point quickly. No fillers.
### Clear
Short sentences.
No ambiguity.
**Example:**
“Standings updated.”
### Neutral
No opinion.
No hype.
No exaggeration.
### **Minimal**
We remove unnecessary words.
Writing should feel clean — like the UI.
### Competent
We assume the user understands racing.
### **Honest**
We never overpromise or exaggerate.
GridPilot says what it does, nothing more.
### **Human**
Natural phrasing, like talking to another racer.
### Professional
We sound like race officials, not marketers.
---
## 3. What We Avoid
### ❌ Corporate language
“scalable solution,” “empower,” “revolutionize,” “synergy”
- marketing language
- motivational phrasing
- emotional exaggeration
- jokes
- slang
- buzzwords
- storytelling tone
### ❌ Startup hype
“game-changing,” “disruptive,” “next-gen”
### ❌ 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.
GridPilot is not here to entertain.
It is here to **operate**.
---
## 4. Tone by Context
### **Landing Page**
- calm
- confident
- benefit-focused
- no hype
- welcoming
Example:
“League racing should feel organized. GridPilot brings everything into one place.”
---
### **In-App UI**
- crisp
### UI
- direct
- factual
- tool-like
Examples:
- “Season created.”
- “Driver approved.”
- “Penalty applied.”
---
### Errors
- neutral
- small sentences
Examples:
“Race added.”
“Results imported.”
“Penalty applied.”
---
### **Notifications**
- non-intrusive
- soft
- clear
Examples:
“Your next race is tomorrow.”
“Standings updated.”
---
### **Errors**
- helpful
- steady
- calm
- no blame
Examples:
“Something went wrong. Try again.”
“Connection lost. Reconnecting…
- “Action failed.”
- “Invalid input.
- “Try again.”
---
### **Emails**
- friendly
- simple
### Notifications
- short
- non-intrusive
Examples:
- “Race starts in 30 minutes.”
- “Results updated.”
---
### Emails
- factual
- brief
- no sales language
Example:
“Your season starts next week. Heres the schedule.”
“Your season schedule has been updated.”
---
## 5. Writing Style Rules
## 5. Writing Rules
### **Short sentences**
Easy to read. Easy to scan.
### **Simple verbs**
Join, manage, view, race, update.
### **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.
- remove unnecessary adjectives
- use active voice
- one idea per sentence
- no filler
- no persuasion
---
## 6. Phrases We Like
## 6. Phrases We Prefer
-Everything in one place.”
-Your racing identity.”
-Clean standings.”
-All data in one place.”
-Clear standings.”
-Structured seasons.”
- “Consistent rules.”
- “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”
- “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.**
**GridPilot speaks like race control: calm, precise, and trustworthy.**

View File

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

View File

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