wip
This commit is contained in:
@@ -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 user’s 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:
|
||||
- exact file paths
|
||||
- content excerpts when needed
|
||||
- constraints
|
||||
- expected output
|
||||
- what must NOT be touched
|
||||
- any relevant test or behavior definition
|
||||
For every delegation, the Orchestrator MUST provide:
|
||||
- exact file paths
|
||||
- exact scope
|
||||
- exact operations
|
||||
- constraints
|
||||
- what must NOT be touched
|
||||
- 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
|
||||
@@ -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;
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
57
apps/website/components/leagues/PenaltyCardMenu.tsx
Normal file
57
apps/website/components/leagues/PenaltyCardMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
apps/website/components/leagues/PenaltyFAB.tsx
Normal file
23
apps/website/components/leagues/PenaltyFAB.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
41
apps/website/components/races/InlinePenaltyButton.tsx
Normal file
41
apps/website/components/races/InlinePenaltyButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
270
docs/THEME.md
270
docs/THEME.md
@@ -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 (30–50px)
|
||||
- layered parallax for content panels
|
||||
- 150–250ms total
|
||||
Never to impress.
|
||||
|
||||
Feels warm, inviting, non-static.
|
||||
### Characteristics:
|
||||
- short durations (120–200ms)
|
||||
- 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 (6–8px)
|
||||
- soft shadow (blur 20–28px)
|
||||
- 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 (60–120ms)
|
||||
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 user’s 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.**
|
||||
220
docs/VOICE.md
220
docs/VOICE.md
@@ -1,197 +1,123 @@
|
||||
# GridPilot — Voice & Tone Guide
|
||||
*A calm, clear, confident voice built for sim racers.*
|
||||
# GridPilot — Voice & Tone Guide
|
||||
|
||||
*A calm, neutral, authoritative voice for motorsport infrastructure.*
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Personality
|
||||
|
||||
GridPilot’s 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
|
||||
“let’s 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
|
||||
### UI
|
||||
- direct
|
||||
- factual
|
||||
- tool-like
|
||||
|
||||
Example:
|
||||
“League racing should feel organized. GridPilot brings everything into one place.”
|
||||
Examples:
|
||||
- “Season created.”
|
||||
- “Driver approved.”
|
||||
- “Penalty applied.”
|
||||
|
||||
---
|
||||
|
||||
### **In-App UI**
|
||||
- crisp
|
||||
- tool-like
|
||||
- neutral
|
||||
- small sentences
|
||||
### Errors
|
||||
- neutral
|
||||
- calm
|
||||
- no blame
|
||||
|
||||
Examples:
|
||||
“Race added.”
|
||||
“Results imported.”
|
||||
“Penalty applied.”
|
||||
Examples:
|
||||
- “Action failed.”
|
||||
- “Invalid input.”
|
||||
- “Try again.”
|
||||
|
||||
---
|
||||
|
||||
### **Notifications**
|
||||
- non-intrusive
|
||||
- soft
|
||||
- clear
|
||||
### Notifications
|
||||
- short
|
||||
- non-intrusive
|
||||
|
||||
Examples:
|
||||
“Your next race is tomorrow.”
|
||||
“Standings updated.”
|
||||
Examples:
|
||||
- “Race starts in 30 minutes.”
|
||||
- “Results updated.”
|
||||
|
||||
---
|
||||
|
||||
### **Errors**
|
||||
- helpful
|
||||
- steady
|
||||
- no blame
|
||||
### Emails
|
||||
- factual
|
||||
- brief
|
||||
- no sales language
|
||||
|
||||
Examples:
|
||||
“Something went wrong. Try again.”
|
||||
“Connection lost. Reconnecting…”
|
||||
Example:
|
||||
“Your season schedule has been updated.”
|
||||
|
||||
---
|
||||
|
||||
### **Emails**
|
||||
- friendly
|
||||
- simple
|
||||
- short
|
||||
## 5. Writing Rules
|
||||
|
||||
Example:
|
||||
“Your season starts next week. Here’s the schedule.”
|
||||
- remove unnecessary adjectives
|
||||
- use active voice
|
||||
- one idea per sentence
|
||||
- no filler
|
||||
- no persuasion
|
||||
|
||||
---
|
||||
|
||||
## 5. Writing Style Rules
|
||||
## 6. Phrases We Prefer
|
||||
|
||||
### **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.
|
||||
- “All data in one place.”
|
||||
- “Clear standings.”
|
||||
- “Structured seasons.”
|
||||
- “Consistent rules.”
|
||||
- “No spreadsheets.”
|
||||
|
||||
---
|
||||
|
||||
## 6. Phrases We Like
|
||||
## 7. One-line Voice Summary
|
||||
|
||||
- “Everything in one place.”
|
||||
- “Your racing identity.”
|
||||
- “Clean standings.”
|
||||
- “No spreadsheets.”
|
||||
- “Race. We handle the rest.”
|
||||
- “Built for league racing.”
|
||||
- “Clear. Simple. Consistent.”
|
||||
|
||||
These reinforce clarity and confidence.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phrases We Never Use
|
||||
|
||||
- “premium experience”
|
||||
- “unlock your potential”
|
||||
- “cutting-edge AI”
|
||||
- “transform the sim racing landscape”
|
||||
- “we’re 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.**
|
||||
@@ -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';
|
||||
|
||||
@@ -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
BIN
race-results-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -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,
|
||||
);
|
||||
|
||||
123
tests/unit/website/InlinePenaltyButton.test.tsx
Normal file
123
tests/unit/website/InlinePenaltyButton.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user