diff --git a/.roo/rules-orchestrator/rules.md b/.roo/rules-orchestrator/rules.md index c0a194409..48dc97ee9 100644 --- a/.roo/rules-orchestrator/rules.md +++ b/.roo/rules-orchestrator/rules.md @@ -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 \ No newline at end of file +A delegation is valid only when: +- exactly one domain is chosen +- exactly one expert is assigned +- context is complete +- task scope is single-purpose \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index 692918697..d97709955 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -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(null); const [refreshKey, setRefreshKey] = useState(0); + const [endRaceModalRaceId, setEndRaceModalRaceId] = useState(null); const currentDriverId = useEffectiveDriverId(); const membership = getMembership(leagueId, currentDriverId); @@ -347,22 +349,7 @@ export default function LeagueDetailPage() { {membership?.role === 'admin' && ( + + {showQuickPenaltyModal && ( + + )} ); @@ -298,6 +337,8 @@ export default function RaceResultsPage() { fastestLapTime={fastestLapTime ?? 0} penalties={penalties} currentDriverId={currentDriverId ?? ''} + isAdmin={isAdmin} + onPenaltyClick={handlePenaltyClick} /> ) : ( <> diff --git a/apps/website/components/leagues/PenaltyCardMenu.tsx b/apps/website/components/leagues/PenaltyCardMenu.tsx new file mode 100644 index 000000000..68a7db92a --- /dev/null +++ b/apps/website/components/leagues/PenaltyCardMenu.tsx @@ -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 ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+ + +
+ + )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/PenaltyFAB.tsx b/apps/website/components/leagues/PenaltyFAB.tsx new file mode 100644 index 000000000..3edae0a3c --- /dev/null +++ b/apps/website/components/leagues/PenaltyFAB.tsx @@ -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 ( +
+ +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/QuickPenaltyModal.tsx b/apps/website/components/leagues/QuickPenaltyModal.tsx index ea848799b..1654d9b22 100644 --- a/apps/website/components/leagues/QuickPenaltyModal.tsx +++ b/apps/website/components/leagues/QuickPenaltyModal.tsx @@ -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(''); +export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelectedDriver, adminId, races }: QuickPenaltyModalProps) { + const [selectedRaceId, setSelectedRaceId] = useState(raceId || ''); + const [selectedDriver, setSelectedDriver] = useState(preSelectedDriver?.id || ''); const [infractionType, setInfractionType] = useState(''); const [severity, setSeverity] = useState(''); const [notes, setNotes] = useState(''); @@ -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

Quick Penalty

+ {/* Race Selection */} + {races && !raceId && ( +
+ + +
+ )} + {/* Driver Selection */}
- + {preSelectedDriver ? ( +
+ {preSelectedDriver.name} +
+ ) : ( + + )}
{/* 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'} diff --git a/apps/website/components/races/InlinePenaltyButton.tsx b/apps/website/components/races/InlinePenaltyButton.tsx new file mode 100644 index 000000000..0e5ace36e --- /dev/null +++ b/apps/website/components/races/InlinePenaltyButton.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/apps/website/components/races/ResultsTable.tsx b/apps/website/components/races/ResultsTable.tsx index 6bdc19a73..0061efca8 100644 --- a/apps/website/components/races/ResultsTable.tsx +++ b/apps/website/components/races/ResultsTable.tsx @@ -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({ Points +/- Penalties + {isAdmin && Actions} @@ -246,6 +252,17 @@ export default function ResultsTable({ — )} + {isAdmin && ( + + {driver && onPenaltyClick && ( + + )} + + )} ); })} diff --git a/apps/website/lib/di-config.ts b/apps/website/lib/di-config.ts index 5214a64f8..41b034677 100644 --- a/apps/website/lib/di-config.ts +++ b/apps/website/lib/di-config.ts @@ -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 ) diff --git a/apps/website/lib/di-container.ts b/apps/website/lib/di-container.ts index e61ab5bde..74a0d98d7 100644 --- a/apps/website/lib/di-container.ts +++ b/apps/website/lib/di-container.ts @@ -57,6 +57,7 @@ import type { FileProtestUseCase, ReviewProtestUseCase, ApplyPenaltyUseCase, + QuickPenaltyUseCase, RequestProtestDefenseUseCase, SubmitProtestDefenseUseCase, GetSponsorDashboardUseCase, @@ -490,6 +491,11 @@ class DIContainer { return getDIContainer().resolve(DI_TOKENS.ApplyPenaltyUseCase); } + get quickPenaltyUseCase(): QuickPenaltyUseCase { + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.QuickPenaltyUseCase); + } + get getRaceProtestsUseCase(): GetRaceProtestsUseCase { this.ensureInitialized(); return getDIContainer().resolve(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; } diff --git a/apps/website/lib/di-tokens.ts b/apps/website/lib/di-tokens.ts index 5e16cad1a..ddcb136af 100644 --- a/apps/website/lib/di-tokens.ts +++ b/apps/website/lib/di-tokens.ts @@ -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'), diff --git a/docs/THEME.md b/docs/THEME.md index 6a6ab7b4d..a14b2a773 100644 --- a/docs/THEME.md +++ b/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. \ No newline at end of file +It is: +**modern motorsport infrastructure software.** \ No newline at end of file diff --git a/docs/VOICE.md b/docs/VOICE.md index e82a9169b..c8912ba64 100644 --- a/docs/VOICE.md +++ b/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.** \ No newline at end of file +**GridPilot speaks like race control: calm, precise, and trustworthy.** \ No newline at end of file diff --git a/packages/racing/application/index.ts b/packages/racing/application/index.ts index 2b6dba64a..a363c5d1b 100644 --- a/packages/racing/application/index.ts +++ b/packages/racing/application/index.ts @@ -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'; diff --git a/packages/racing/application/use-cases/ImportRaceResultsUseCase.ts b/packages/racing/application/use-cases/ImportRaceResultsUseCase.ts index 69b62f6be..e20324166 100644 --- a/packages/racing/application/use-cases/ImportRaceResultsUseCase.ts +++ b/packages/racing/application/use-cases/ImportRaceResultsUseCase.ts @@ -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, + }); }), ); diff --git a/race-results-page.png b/race-results-page.png new file mode 100644 index 000000000..be0a7fc21 Binary files /dev/null and b/race-results-page.png differ diff --git a/tests/unit/racing-application/RaceResultsUseCases.test.ts b/tests/unit/racing-application/RaceResultsUseCases.test.ts index f1d5fda4d..a414c8c19 100644 --- a/tests/unit/racing-application/RaceResultsUseCases.test.ts +++ b/tests/unit/racing-application/RaceResultsUseCases.test.ts @@ -285,10 +285,31 @@ describe('ImportRaceResultsUseCase', () => { const presenter = new FakeImportRaceResultsPresenter(); + const driverRepository = { + findById: async (): Promise => null, + findByIRacingId: async (iracingId: string): Promise => { + // 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 => [], + create: async (): Promise => { throw new Error('Not implemented'); }, + update: async (): Promise => { throw new Error('Not implemented'); }, + delete: async (): Promise => { throw new Error('Not implemented'); }, + exists: async (): Promise => false, + existsByIRacingId: async (): Promise => false, + }; + const useCase = new ImportRaceResultsUseCase( raceRepository, leagueRepository, resultRepository, + driverRepository, standingRepository, presenter, ); diff --git a/tests/unit/website/InlinePenaltyButton.test.tsx b/tests/unit/website/InlinePenaltyButton.test.tsx new file mode 100644 index 000000000..148e295ff --- /dev/null +++ b/tests/unit/website/InlinePenaltyButton.test.tsx @@ -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) => ( + + ), +})); + +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( + + ); + + const button = screen.queryByTestId('penalty-button'); + expect(button).not.toBeInTheDocument(); + }); + + it('should render when user is admin', () => { + render( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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'); + }); +}); \ No newline at end of file