wip
This commit is contained in:
@@ -1,133 +1,127 @@
|
|||||||
# 🧭 Orchestrator
|
# 🧭 Orchestrator
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Interpret the user's intent, gather complete context,
|
Interpret the user’s intent, gather full context, decide the correct execution domain,
|
||||||
and delegate work as clear, cohesive subtasks to the correct experts.
|
and delegate a **single, clear task** to **exactly one expert**.
|
||||||
|
|
||||||
The Orchestrator never performs expert work.
|
The Orchestrator never performs expert work.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## User Supremacy
|
## Absolute Rule: NEVER BOTH
|
||||||
- The user overrides all internal rules.
|
A task MUST NEVER be assigned to both frontend and backend at the same time.
|
||||||
- The Orchestrator must stop all ongoing processes and adapt immediately when the user issues a new instruction.
|
|
||||||
- No reinterpretation or negotiation.
|
If a user request touches frontend and backend:
|
||||||
|
- the Orchestrator MUST split it into **separate subtasks**
|
||||||
|
- each subtask is delegated **independently**
|
||||||
|
- each subtask targets **exactly one domain**
|
||||||
|
|
||||||
|
There is NO exception to this rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mandatory Domain Decision (Before Every Delegation)
|
||||||
|
Before delegating ANY task, the Orchestrator MUST explicitly decide:
|
||||||
|
|
||||||
|
- **Frontend**
|
||||||
|
OR
|
||||||
|
- **Backend**
|
||||||
|
|
||||||
|
If the task cannot be clearly classified:
|
||||||
|
- do NOT delegate
|
||||||
|
- use Clarification first
|
||||||
|
|
||||||
|
The Orchestrator MUST NOT guess.
|
||||||
|
The Orchestrator MUST NOT default to backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Routing Rules
|
||||||
|
Delegate to **Frontend Coder** if the task involves ANY of:
|
||||||
|
- React / Next.js
|
||||||
|
- pages, layouts, routes
|
||||||
|
- JSX / TSX
|
||||||
|
- UI components
|
||||||
|
- hooks, props, state
|
||||||
|
- styling, CSS, Tailwind
|
||||||
|
- DOM behavior
|
||||||
|
- UX flows
|
||||||
|
- client/server components
|
||||||
|
- frontend validation
|
||||||
|
- view models used only by UI
|
||||||
|
|
||||||
|
If any item applies → Frontend Coder ONLY.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Routing Rules
|
||||||
|
Delegate to **Backend Coder** if the task involves ANY of:
|
||||||
|
- domain logic
|
||||||
|
- application logic
|
||||||
|
- use cases / interactors
|
||||||
|
- DTOs (application-level)
|
||||||
|
- repositories
|
||||||
|
- ports / adapters
|
||||||
|
- persistence
|
||||||
|
- services
|
||||||
|
- business rules
|
||||||
|
- backend validation
|
||||||
|
- infrastructure
|
||||||
|
|
||||||
|
If any item applies → Backend Coder ONLY.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Splitting Rule
|
||||||
|
If a user request includes:
|
||||||
|
- frontend changes AND backend changes
|
||||||
|
|
||||||
|
The Orchestrator MUST:
|
||||||
|
1. create a frontend subtask
|
||||||
|
2. create a backend subtask
|
||||||
|
3. delegate them separately
|
||||||
|
4. never combine them
|
||||||
|
5. never delegate “both” to one coder
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Context Responsibility
|
## Context Responsibility
|
||||||
The Orchestrator MUST provide:
|
For every delegation, the Orchestrator MUST provide:
|
||||||
- exact file paths
|
- exact file paths
|
||||||
- content excerpts when needed
|
- exact scope
|
||||||
|
- exact operations
|
||||||
- constraints
|
- constraints
|
||||||
- expected output
|
|
||||||
- what must NOT be touched
|
- what must NOT be touched
|
||||||
- any relevant test or behavior definition
|
- expected outcome
|
||||||
|
|
||||||
Experts must NEVER collect context themselves.
|
Experts MUST NOT gather context themselves.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task Grouping
|
## User Supremacy
|
||||||
The Orchestrator MUST:
|
If the user explicitly says:
|
||||||
- **merge related work into one cohesive subtask**
|
- “ignore separation”
|
||||||
- **split unrelated work into multiple subtasks**
|
- “do frontend only”
|
||||||
- assign each subtask to exactly one expert
|
- “do backend only”
|
||||||
- never mix concerns or layers
|
|
||||||
|
|
||||||
A subtask must always be:
|
The Orchestrator MUST obey exactly as stated.
|
||||||
- self-contained
|
|
||||||
- minimal
|
|
||||||
- fully scoped
|
|
||||||
- executable
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TODO List Responsibility (Critical)
|
|
||||||
The Orchestrator MUST maintain a **strict, accurate TODO list**.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. When the user gives ANY instruction →
|
|
||||||
**the Orchestrator MUST generate or update a TODO list.**
|
|
||||||
|
|
||||||
2. TODO list must contain **ONLY outstanding, unfinished work**.
|
|
||||||
- No completed items.
|
|
||||||
- No redundant items.
|
|
||||||
- No invented tasks.
|
|
||||||
- No assumptions.
|
|
||||||
|
|
||||||
3. Each TODO item must be:
|
|
||||||
- explicit
|
|
||||||
- actionable
|
|
||||||
- minimal
|
|
||||||
- atomic (one responsibility per item)
|
|
||||||
|
|
||||||
4. The TODO list MUST represent the **true, current state** of what remains.
|
|
||||||
- If something is already done → DO NOT list it
|
|
||||||
- If something is irrelevant → DO NOT list it
|
|
||||||
- If something is repeated → collapse to one item
|
|
||||||
|
|
||||||
5. The TODO list is the **single source of truth** for remaining work.
|
|
||||||
|
|
||||||
6. Experts NEVER update TODOs.
|
|
||||||
Only the Orchestrator modifies TODOs.
|
|
||||||
|
|
||||||
7. After each expert result:
|
|
||||||
- The Orchestrator MUST update the TODO list (finish/remove completed items, keep only outstanding ones).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Delegation Rules
|
|
||||||
A delegation MUST be:
|
|
||||||
- direct
|
|
||||||
- unambiguous
|
|
||||||
- fully scoped
|
|
||||||
- context-complete
|
|
||||||
- zero explanations
|
|
||||||
- no options
|
|
||||||
- no reasoning
|
|
||||||
|
|
||||||
Format guidelines:
|
|
||||||
- “Here is the context.”
|
|
||||||
- “Here is the task.”
|
|
||||||
- “Do exactly this and nothing else.”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Interruptibility
|
|
||||||
When the user issues a new instruction:
|
|
||||||
- stop all running tasks
|
|
||||||
- discard previous assumptions
|
|
||||||
- rebuild TODO list
|
|
||||||
- delegate new work
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Efficiency
|
|
||||||
The Orchestrator MUST:
|
|
||||||
- minimize the number of subtasks
|
|
||||||
- avoid duplicated work
|
|
||||||
- ensure no overlapping instructions
|
|
||||||
- keep the workflow deterministic
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Forbidden
|
## Forbidden
|
||||||
The Orchestrator MUST NOT:
|
The Orchestrator MUST NOT:
|
||||||
- perform expert-level reasoning
|
- delegate mixed frontend/backend tasks
|
||||||
- propose solutions
|
- say “both”
|
||||||
- give architecture opinions
|
- let coders decide responsibility
|
||||||
- write plans
|
- assume backend by default
|
||||||
- describe implementations
|
- rush to delegation without classification
|
||||||
- output long explanations
|
- merge unrelated work
|
||||||
- generate TODOs that are already done
|
|
||||||
- expand or reduce user intent
|
|
||||||
- run tests
|
|
||||||
- edit files
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Completion
|
## Completion
|
||||||
A step is complete when:
|
A delegation is valid only when:
|
||||||
- the assigned expert returns the result
|
- exactly one domain is chosen
|
||||||
- the TODO list is updated to reflect ONLY what is still outstanding
|
- exactly one expert is assigned
|
||||||
- the Orchestrator either delegates the next TODO or waits for user input
|
- context is complete
|
||||||
|
- task scope is single-purpose
|
||||||
@@ -6,6 +6,7 @@ import Button from '@/components/ui/Button';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
|
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
|
||||||
import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed';
|
import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed';
|
||||||
|
import EndRaceModal from '@/components/leagues/EndRaceModal';
|
||||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
||||||
import SponsorInsightsCard, {
|
import SponsorInsightsCard, {
|
||||||
@@ -68,6 +69,7 @@ export default function LeagueDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [endRaceModalRaceId, setEndRaceModalRaceId] = useState<string | null>(null);
|
||||||
|
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
const membership = getMembership(leagueId, currentDriverId);
|
const membership = getMembership(leagueId, currentDriverId);
|
||||||
@@ -347,22 +349,7 @@ export default function LeagueDetailPage() {
|
|||||||
{membership?.role === 'admin' && (
|
{membership?.role === 'admin' && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={async () => {
|
onClick={() => setEndRaceModalRaceId(race.id)}
|
||||||
const confirmed = window.confirm(
|
|
||||||
'Are you sure you want to end this race and process results?\n\nThis will mark the race as completed and calculate final standings.'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const completeRace = getCompleteRaceUseCase();
|
|
||||||
await completeRace.execute({ raceId: race.id });
|
|
||||||
// Reload league data to reflect the completed race
|
|
||||||
await loadLeagueData();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : 'Failed to complete race');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="border-performance-green/50 text-performance-green hover:bg-performance-green/10"
|
className="border-performance-green/50 text-performance-green hover:bg-performance-green/10"
|
||||||
>
|
>
|
||||||
End Race & Process Results
|
End Race & Process Results
|
||||||
@@ -684,6 +671,28 @@ export default function LeagueDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* End Race Modal */}
|
||||||
|
{endRaceModalRaceId && (() => {
|
||||||
|
const race = runningRaces.find(r => r.id === endRaceModalRaceId);
|
||||||
|
return race ? (
|
||||||
|
<EndRaceModal
|
||||||
|
raceId={race.id}
|
||||||
|
raceName={race.track}
|
||||||
|
onConfirm={async () => {
|
||||||
|
try {
|
||||||
|
const completeRace = getCompleteRaceUseCase();
|
||||||
|
await completeRace.execute({ raceId: race.id });
|
||||||
|
await loadLeagueData();
|
||||||
|
setEndRaceModalRaceId(null);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to complete race');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => setEndRaceModalRaceId(null)}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,8 @@ import Link from 'next/link';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
|
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
|
||||||
|
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
|
||||||
|
import PenaltyFAB from '@/components/leagues/PenaltyFAB';
|
||||||
import {
|
import {
|
||||||
getRaceRepository,
|
getRaceRepository,
|
||||||
getProtestRepository,
|
getProtestRepository,
|
||||||
@@ -43,11 +45,13 @@ export default function LeagueStewardingPage() {
|
|||||||
const [protestsByRace, setProtestsByRace] = useState<Record<string, Protest[]>>({});
|
const [protestsByRace, setProtestsByRace] = useState<Record<string, Protest[]>>({});
|
||||||
const [penaltiesByRace, setPenaltiesByRace] = useState<Record<string, Penalty[]>>({});
|
const [penaltiesByRace, setPenaltiesByRace] = useState<Record<string, Penalty[]>>({});
|
||||||
const [driversById, setDriversById] = useState<Record<string, DriverDTO>>({});
|
const [driversById, setDriversById] = useState<Record<string, DriverDTO>>({});
|
||||||
|
const [allDrivers, setAllDrivers] = useState<DriverDTO[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
|
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
|
||||||
const [selectedProtest, setSelectedProtest] = useState<Protest | null>(null);
|
const [selectedProtest, setSelectedProtest] = useState<Protest | null>(null);
|
||||||
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
|
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
|
||||||
|
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function checkAdmin() {
|
async function checkAdmin() {
|
||||||
@@ -110,6 +114,7 @@ export default function LeagueStewardingPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
setDriversById(byId);
|
setDriversById(byId);
|
||||||
|
setAllDrivers(Object.values(byId));
|
||||||
|
|
||||||
// Auto-expand races with pending protests
|
// Auto-expand races with pending protests
|
||||||
const racesWithPending = new Set<string>();
|
const racesWithPending = new Set<string>();
|
||||||
@@ -495,6 +500,10 @@ export default function LeagueStewardingPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{activeTab === 'history' && (
|
||||||
|
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedProtest && (
|
{selectedProtest && (
|
||||||
<ReviewProtestModal
|
<ReviewProtestModal
|
||||||
protest={selectedProtest}
|
protest={selectedProtest}
|
||||||
@@ -503,6 +512,15 @@ export default function LeagueStewardingPage() {
|
|||||||
onReject={handleRejectProtest}
|
onReject={handleRejectProtest}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showQuickPenaltyModal && (
|
||||||
|
<QuickPenaltyModal
|
||||||
|
drivers={allDrivers}
|
||||||
|
onClose={() => setShowQuickPenaltyModal(false)}
|
||||||
|
adminId={currentDriverId}
|
||||||
|
races={races.map(r => ({ id: r.id, track: r.track, scheduledAt: r.scheduledAt }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import Card from '@/components/ui/Card';
|
|||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||||
import FileProtestModal from '@/components/races/FileProtestModal';
|
import FileProtestModal from '@/components/races/FileProtestModal';
|
||||||
|
import EndRaceModal from '@/components/leagues/EndRaceModal';
|
||||||
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
|
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
|
||||||
import { getGetRaceDetailUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, getCancelRaceUseCase, getCompleteRaceUseCase } from '@/lib/di-container';
|
import { getGetRaceDetailUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, getCancelRaceUseCase, getCompleteRaceUseCase } from '@/lib/di-container';
|
||||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||||
@@ -50,6 +51,7 @@ export default function RaceDetailPage() {
|
|||||||
const [ratingChange, setRatingChange] = useState<number | null>(null);
|
const [ratingChange, setRatingChange] = useState<number | null>(null);
|
||||||
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
|
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
|
||||||
const [showProtestModal, setShowProtestModal] = useState(false);
|
const [showProtestModal, setShowProtestModal] = useState(false);
|
||||||
|
const [showEndRaceModal, setShowEndRaceModal] = useState(false);
|
||||||
const [membership, setMembership] = useState<any>(null);
|
const [membership, setMembership] = useState<any>(null);
|
||||||
|
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
@@ -928,22 +930,7 @@ export default function RaceDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="w-full flex items-center justify-center gap-2"
|
className="w-full flex items-center justify-center gap-2"
|
||||||
onClick={async () => {
|
onClick={() => setShowEndRaceModal(true)}
|
||||||
const confirmed = window.confirm(
|
|
||||||
'Are you sure you want to end this race and process results?\n\nThis will mark the race as completed and calculate final standings.'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const completeRace = getCompleteRaceUseCase();
|
|
||||||
await completeRace.execute({ raceId: race.id });
|
|
||||||
// Reload race data to reflect the completed race
|
|
||||||
await loadRaceData();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : 'Failed to complete race');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
End Race & Process Results
|
End Race & Process Results
|
||||||
@@ -989,6 +976,25 @@ export default function RaceDetailPage() {
|
|||||||
protestingDriverId={currentDriverId}
|
protestingDriverId={currentDriverId}
|
||||||
participants={entryList.map(d => ({ id: d.id, name: d.name }))}
|
participants={entryList.map(d => ({ id: d.id, name: d.name }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* End Race Modal */}
|
||||||
|
{showEndRaceModal && (
|
||||||
|
<EndRaceModal
|
||||||
|
raceId={race.id}
|
||||||
|
raceName={race.track}
|
||||||
|
onConfirm={async () => {
|
||||||
|
try {
|
||||||
|
const completeRace = getCompleteRaceUseCase();
|
||||||
|
await completeRace.execute({ raceId: race.id });
|
||||||
|
await loadRaceData();
|
||||||
|
setShowEndRaceModal(false);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : 'Failed to complete race');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowEndRaceModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,11 +8,15 @@ import Card from '@/components/ui/Card';
|
|||||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||||
import ResultsTable from '@/components/races/ResultsTable';
|
import ResultsTable from '@/components/races/ResultsTable';
|
||||||
import ImportResultsForm from '@/components/races/ImportResultsForm';
|
import ImportResultsForm from '@/components/races/ImportResultsForm';
|
||||||
|
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
|
||||||
import {
|
import {
|
||||||
getGetRaceWithSOFUseCase,
|
getGetRaceWithSOFUseCase,
|
||||||
getGetRaceResultsDetailUseCase,
|
getGetRaceResultsDetailUseCase,
|
||||||
getImportRaceResultsUseCase,
|
getImportRaceResultsUseCase,
|
||||||
|
getLeagueMembershipRepository,
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||||
|
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||||
import { RaceWithSOFPresenter } from '@/lib/presenters/RaceWithSOFPresenter';
|
import { RaceWithSOFPresenter } from '@/lib/presenters/RaceWithSOFPresenter';
|
||||||
import { RaceResultsDetailPresenter } from '@/lib/presenters/RaceResultsDetailPresenter';
|
import { RaceResultsDetailPresenter } from '@/lib/presenters/RaceResultsDetailPresenter';
|
||||||
import type {
|
import type {
|
||||||
@@ -65,12 +69,12 @@ export default function RaceResultsPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const raceId = params.id as string;
|
const raceId = params.id as string;
|
||||||
|
const currentDriverId = useEffectiveDriverId();
|
||||||
|
|
||||||
const [race, setRace] = useState<RaceResultsHeaderViewModel | null>(null);
|
const [race, setRace] = useState<RaceResultsHeaderViewModel | null>(null);
|
||||||
const [league, setLeague] = useState<RaceResultsLeagueViewModel | null>(null);
|
const [league, setLeague] = useState<RaceResultsLeagueViewModel | null>(null);
|
||||||
const [results, setResults] = useState<RaceResultRowDTO[]>([]);
|
const [results, setResults] = useState<RaceResultRowDTO[]>([]);
|
||||||
const [drivers, setDrivers] = useState<DriverRowDTO[]>([]);
|
const [drivers, setDrivers] = useState<DriverRowDTO[]>([]);
|
||||||
const [currentDriverId, setCurrentDriverId] = useState<string | undefined>(undefined);
|
|
||||||
const [raceSOF, setRaceSOF] = useState<number | null>(null);
|
const [raceSOF, setRaceSOF] = useState<number | null>(null);
|
||||||
const [penalties, setPenalties] = useState<PenaltyData[]>([]);
|
const [penalties, setPenalties] = useState<PenaltyData[]>([]);
|
||||||
const [pointsSystem, setPointsSystem] = useState<Record<number, number> | undefined>(undefined);
|
const [pointsSystem, setPointsSystem] = useState<Record<number, number> | undefined>(undefined);
|
||||||
@@ -79,6 +83,9 @@ export default function RaceResultsPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [importSuccess, setImportSuccess] = useState(false);
|
const [importSuccess, setImportSuccess] = useState(false);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
|
||||||
|
const [preSelectedDriver, setPreSelectedDriver] = useState<DriverRowDTO | undefined>(undefined);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -103,7 +110,6 @@ export default function RaceResultsPage() {
|
|||||||
setPenalties([]);
|
setPenalties([]);
|
||||||
setPointsSystem({});
|
setPointsSystem({});
|
||||||
setFastestLapTime(undefined);
|
setFastestLapTime(undefined);
|
||||||
setCurrentDriverId(undefined);
|
|
||||||
} else {
|
} else {
|
||||||
setError(null);
|
setError(null);
|
||||||
setRace(viewModel.race);
|
setRace(viewModel.race);
|
||||||
@@ -117,7 +123,6 @@ export default function RaceResultsPage() {
|
|||||||
);
|
);
|
||||||
setPointsSystem(viewModel.pointsSystem);
|
setPointsSystem(viewModel.pointsSystem);
|
||||||
setFastestLapTime(viewModel.fastestLapTime);
|
setFastestLapTime(viewModel.fastestLapTime);
|
||||||
setCurrentDriverId(viewModel.currentDriverId);
|
|
||||||
const mappedPenalties: PenaltyData[] = viewModel.penalties.map((p) => {
|
const mappedPenalties: PenaltyData[] = viewModel.penalties.map((p) => {
|
||||||
const base: PenaltyData = {
|
const base: PenaltyData = {
|
||||||
driverId: p.driverId,
|
driverId: p.driverId,
|
||||||
@@ -154,6 +159,17 @@ export default function RaceResultsPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [raceId]);
|
}, [raceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (league?.id && currentDriverId) {
|
||||||
|
const checkAdmin = async () => {
|
||||||
|
const membershipRepo = getLeagueMembershipRepository();
|
||||||
|
const membership = await membershipRepo.getMembership(league.id, currentDriverId);
|
||||||
|
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||||
|
};
|
||||||
|
checkAdmin();
|
||||||
|
}
|
||||||
|
}, [league?.id, currentDriverId]);
|
||||||
|
|
||||||
const handleImportSuccess = async (importedResults: ImportResultRowDTO[]) => {
|
const handleImportSuccess = async (importedResults: ImportResultRowDTO[]) => {
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -178,6 +194,16 @@ export default function RaceResultsPage() {
|
|||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePenaltyClick = (driver: DriverRowDTO) => {
|
||||||
|
setPreSelectedDriver(driver);
|
||||||
|
setShowQuickPenaltyModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseQuickPenaltyModal = () => {
|
||||||
|
setShowQuickPenaltyModal(false);
|
||||||
|
setPreSelectedDriver(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||||
@@ -203,6 +229,19 @@ export default function RaceResultsPage() {
|
|||||||
Back to Races
|
Back to Races
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{showQuickPenaltyModal && (
|
||||||
|
<QuickPenaltyModal
|
||||||
|
{...({
|
||||||
|
raceId,
|
||||||
|
drivers: drivers as any,
|
||||||
|
onClose: handleCloseQuickPenaltyModal,
|
||||||
|
preSelectedDriver: preSelectedDriver as any,
|
||||||
|
adminId: currentDriverId,
|
||||||
|
races: undefined,
|
||||||
|
} as any)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -298,6 +337,8 @@ export default function RaceResultsPage() {
|
|||||||
fastestLapTime={fastestLapTime ?? 0}
|
fastestLapTime={fastestLapTime ?? 0}
|
||||||
penalties={penalties}
|
penalties={penalties}
|
||||||
currentDriverId={currentDriverId ?? ''}
|
currentDriverId={currentDriverId ?? ''}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onPenaltyClick={handlePenaltyClick}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
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 React, { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { getQuickPenaltyUseCase } from '@/lib/di-container';
|
import { getQuickPenaltyUseCase } from '@/lib/di-container';
|
||||||
import type { Driver } from '@gridpilot/racing/application';
|
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';
|
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DriverOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface QuickPenaltyModalProps {
|
interface QuickPenaltyModalProps {
|
||||||
raceId: string;
|
raceId?: string;
|
||||||
drivers: Driver[];
|
drivers: DriverOption[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
preSelectedDriver?: DriverOption;
|
||||||
|
adminId: string;
|
||||||
|
races?: { id: string; track: string; scheduledAt: Date }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const INFRACTION_TYPES = [
|
const INFRACTION_TYPES = [
|
||||||
@@ -28,8 +35,9 @@ const SEVERITY_LEVELS = [
|
|||||||
{ value: 'severe', label: 'Severe', description: 'Heavy penalty' },
|
{ value: 'severe', label: 'Severe', description: 'Heavy penalty' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function QuickPenaltyModal({ raceId, drivers, onClose }: QuickPenaltyModalProps) {
|
export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelectedDriver, adminId, races }: QuickPenaltyModalProps) {
|
||||||
const [selectedDriver, setSelectedDriver] = useState<string>('');
|
const [selectedRaceId, setSelectedRaceId] = useState<string>(raceId || '');
|
||||||
|
const [selectedDriver, setSelectedDriver] = useState<string>(preSelectedDriver?.id || '');
|
||||||
const [infractionType, setInfractionType] = useState<string>('');
|
const [infractionType, setInfractionType] = useState<string>('');
|
||||||
const [severity, setSeverity] = useState<string>('');
|
const [severity, setSeverity] = useState<string>('');
|
||||||
const [notes, setNotes] = useState<string>('');
|
const [notes, setNotes] = useState<string>('');
|
||||||
@@ -39,21 +47,24 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose }: QuickPen
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!selectedDriver || !infractionType || !severity) return;
|
if (!selectedRaceId || !selectedDriver || !infractionType || !severity) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const useCase = getQuickPenaltyUseCase();
|
const useCase = getQuickPenaltyUseCase();
|
||||||
await useCase.execute({
|
const command: any = {
|
||||||
raceId,
|
raceId: selectedRaceId,
|
||||||
driverId: selectedDriver,
|
driverId: selectedDriver,
|
||||||
adminId: 'driver-1', // TODO: Get from current user context
|
adminId,
|
||||||
infractionType: infractionType as any,
|
infractionType: infractionType as any,
|
||||||
severity: severity as any,
|
severity: severity as any,
|
||||||
notes: notes.trim() || undefined,
|
};
|
||||||
});
|
if (notes.trim()) {
|
||||||
|
command.notes = notes.trim();
|
||||||
|
}
|
||||||
|
await useCase.execute(command);
|
||||||
|
|
||||||
// Refresh the page to show updated results
|
// Refresh the page to show updated results
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@@ -72,24 +83,52 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose }: QuickPen
|
|||||||
<h2 className="text-xl font-bold text-white mb-4">Quick Penalty</h2>
|
<h2 className="text-xl font-bold text-white mb-4">Quick Penalty</h2>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Race Selection */}
|
||||||
|
{races && !raceId && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Race
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedRaceId}
|
||||||
|
onChange={(e) => setSelectedRaceId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:border-primary-blue focus:outline-none"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select race...</option>
|
||||||
|
{races.map((race) => (
|
||||||
|
<option key={race.id} value={race.id}>
|
||||||
|
{race.track} ({race.scheduledAt.toLocaleDateString()})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Driver Selection */}
|
{/* Driver Selection */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Driver
|
Driver
|
||||||
</label>
|
</label>
|
||||||
<select
|
{preSelectedDriver ? (
|
||||||
value={selectedDriver}
|
<div className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white">
|
||||||
onChange={(e) => setSelectedDriver(e.target.value)}
|
{preSelectedDriver.name}
|
||||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:border-primary-blue focus:outline-none"
|
</div>
|
||||||
required
|
) : (
|
||||||
>
|
<select
|
||||||
<option value="">Select driver...</option>
|
value={selectedDriver}
|
||||||
{drivers.map((driver) => (
|
onChange={(e) => setSelectedDriver(e.target.value)}
|
||||||
<option key={driver.id} value={driver.id}>
|
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:border-primary-blue focus:outline-none"
|
||||||
{driver.name}
|
required
|
||||||
</option>
|
>
|
||||||
))}
|
<option value="">Select driver...</option>
|
||||||
</select>
|
{drivers.map((driver) => (
|
||||||
|
<option key={driver.id} value={driver.id}>
|
||||||
|
{driver.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Infraction Type */}
|
{/* Infraction Type */}
|
||||||
@@ -175,7 +214,7 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose }: QuickPen
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={loading || !selectedDriver || !infractionType || !severity}
|
disabled={loading || !selectedRaceId || !selectedDriver || !infractionType || !severity}
|
||||||
>
|
>
|
||||||
{loading ? 'Applying...' : 'Apply Penalty'}
|
{loading ? 'Applying...' : 'Apply Penalty'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
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 Link from 'next/link';
|
||||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||||
|
import InlinePenaltyButton from './InlinePenaltyButton';
|
||||||
|
|
||||||
type PenaltyTypeDTO =
|
type PenaltyTypeDTO =
|
||||||
| 'time_penalty'
|
| 'time_penalty'
|
||||||
@@ -41,6 +42,8 @@ interface ResultsTableProps {
|
|||||||
fastestLapTime?: number | undefined;
|
fastestLapTime?: number | undefined;
|
||||||
penalties?: PenaltyData[];
|
penalties?: PenaltyData[];
|
||||||
currentDriverId?: string | undefined;
|
currentDriverId?: string | undefined;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
onPenaltyClick?: (driver: DriverDTO) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResultsTable({
|
export default function ResultsTable({
|
||||||
@@ -50,6 +53,8 @@ export default function ResultsTable({
|
|||||||
fastestLapTime,
|
fastestLapTime,
|
||||||
penalties = [],
|
penalties = [],
|
||||||
currentDriverId,
|
currentDriverId,
|
||||||
|
isAdmin = false,
|
||||||
|
onPenaltyClick,
|
||||||
}: ResultsTableProps) {
|
}: ResultsTableProps) {
|
||||||
const getDriver = (driverId: string): DriverDTO | undefined => {
|
const getDriver = (driverId: string): DriverDTO | undefined => {
|
||||||
return drivers.find((d) => d.id === driverId);
|
return drivers.find((d) => d.id === driverId);
|
||||||
@@ -118,6 +123,7 @@ export default function ResultsTable({
|
|||||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Points</th>
|
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Points</th>
|
||||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">+/-</th>
|
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">+/-</th>
|
||||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Penalties</th>
|
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Penalties</th>
|
||||||
|
{isAdmin && <th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Actions</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -246,6 +252,17 @@ export default function ResultsTable({
|
|||||||
<span className="text-gray-500">—</span>
|
<span className="text-gray-500">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
{isAdmin && (
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
{driver && onPenaltyClick && (
|
||||||
|
<InlinePenaltyButton
|
||||||
|
driver={driver}
|
||||||
|
onPenaltyClick={onPenaltyClick}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ import {
|
|||||||
FileProtestUseCase,
|
FileProtestUseCase,
|
||||||
ReviewProtestUseCase,
|
ReviewProtestUseCase,
|
||||||
ApplyPenaltyUseCase,
|
ApplyPenaltyUseCase,
|
||||||
|
QuickPenaltyUseCase,
|
||||||
RequestProtestDefenseUseCase,
|
RequestProtestDefenseUseCase,
|
||||||
SubmitProtestDefenseUseCase,
|
SubmitProtestDefenseUseCase,
|
||||||
GetSponsorDashboardUseCase,
|
GetSponsorDashboardUseCase,
|
||||||
@@ -988,6 +989,15 @@ export function configureDIContainer(): void {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
container.registerInstance(
|
||||||
|
DI_TOKENS.QuickPenaltyUseCase,
|
||||||
|
new QuickPenaltyUseCase(
|
||||||
|
penaltyRepository,
|
||||||
|
raceRepository,
|
||||||
|
leagueMembershipRepository
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.RequestProtestDefenseUseCase,
|
DI_TOKENS.RequestProtestDefenseUseCase,
|
||||||
new RequestProtestDefenseUseCase(protestRepository, raceRepository, leagueMembershipRepository)
|
new RequestProtestDefenseUseCase(protestRepository, raceRepository, leagueMembershipRepository)
|
||||||
@@ -1182,6 +1192,7 @@ export function configureDIContainer(): void {
|
|||||||
raceRepository,
|
raceRepository,
|
||||||
leagueRepository,
|
leagueRepository,
|
||||||
resultRepository,
|
resultRepository,
|
||||||
|
driverRepository,
|
||||||
standingRepository,
|
standingRepository,
|
||||||
importRaceResultsPresenter
|
importRaceResultsPresenter
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import type {
|
|||||||
FileProtestUseCase,
|
FileProtestUseCase,
|
||||||
ReviewProtestUseCase,
|
ReviewProtestUseCase,
|
||||||
ApplyPenaltyUseCase,
|
ApplyPenaltyUseCase,
|
||||||
|
QuickPenaltyUseCase,
|
||||||
RequestProtestDefenseUseCase,
|
RequestProtestDefenseUseCase,
|
||||||
SubmitProtestDefenseUseCase,
|
SubmitProtestDefenseUseCase,
|
||||||
GetSponsorDashboardUseCase,
|
GetSponsorDashboardUseCase,
|
||||||
@@ -490,6 +491,11 @@ class DIContainer {
|
|||||||
return getDIContainer().resolve<ApplyPenaltyUseCase>(DI_TOKENS.ApplyPenaltyUseCase);
|
return getDIContainer().resolve<ApplyPenaltyUseCase>(DI_TOKENS.ApplyPenaltyUseCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get quickPenaltyUseCase(): QuickPenaltyUseCase {
|
||||||
|
this.ensureInitialized();
|
||||||
|
return getDIContainer().resolve<QuickPenaltyUseCase>(DI_TOKENS.QuickPenaltyUseCase);
|
||||||
|
}
|
||||||
|
|
||||||
get getRaceProtestsUseCase(): GetRaceProtestsUseCase {
|
get getRaceProtestsUseCase(): GetRaceProtestsUseCase {
|
||||||
this.ensureInitialized();
|
this.ensureInitialized();
|
||||||
return getDIContainer().resolve<GetRaceProtestsUseCase>(DI_TOKENS.GetRaceProtestsUseCase);
|
return getDIContainer().resolve<GetRaceProtestsUseCase>(DI_TOKENS.GetRaceProtestsUseCase);
|
||||||
@@ -871,6 +877,10 @@ export function getApplyPenaltyUseCase(): ApplyPenaltyUseCase {
|
|||||||
return DIContainer.getInstance().applyPenaltyUseCase;
|
return DIContainer.getInstance().applyPenaltyUseCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getQuickPenaltyUseCase(): QuickPenaltyUseCase {
|
||||||
|
return DIContainer.getInstance().quickPenaltyUseCase;
|
||||||
|
}
|
||||||
|
|
||||||
export function getGetRaceProtestsUseCase(): GetRaceProtestsUseCase {
|
export function getGetRaceProtestsUseCase(): GetRaceProtestsUseCase {
|
||||||
return DIContainer.getInstance().getRaceProtestsUseCase;
|
return DIContainer.getInstance().getRaceProtestsUseCase;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const DI_TOKENS = {
|
|||||||
FileProtestUseCase: Symbol.for('FileProtestUseCase'),
|
FileProtestUseCase: Symbol.for('FileProtestUseCase'),
|
||||||
ReviewProtestUseCase: Symbol.for('ReviewProtestUseCase'),
|
ReviewProtestUseCase: Symbol.for('ReviewProtestUseCase'),
|
||||||
ApplyPenaltyUseCase: Symbol.for('ApplyPenaltyUseCase'),
|
ApplyPenaltyUseCase: Symbol.for('ApplyPenaltyUseCase'),
|
||||||
|
QuickPenaltyUseCase: Symbol.for('QuickPenaltyUseCase'),
|
||||||
RequestProtestDefenseUseCase: Symbol.for('RequestProtestDefenseUseCase'),
|
RequestProtestDefenseUseCase: Symbol.for('RequestProtestDefenseUseCase'),
|
||||||
SubmitProtestDefenseUseCase: Symbol.for('SubmitProtestDefenseUseCase'),
|
SubmitProtestDefenseUseCase: Symbol.for('SubmitProtestDefenseUseCase'),
|
||||||
|
|
||||||
|
|||||||
270
docs/THEME.md
270
docs/THEME.md
@@ -1,229 +1,141 @@
|
|||||||
# GridPilot Theme — “Smooth Performance Dark”
|
# GridPilot Theme — “Motorsport Infrastructure, Smoothly Engineered”
|
||||||
*A modern, ultra-polished, buttery-smooth interface that feels engineered, premium, and joyful — without losing the seriousness of sim racing.*
|
|
||||||
|
*A precise, professional motorsport interface with premium smoothness — engineered for trust, control, and long-term use.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 1. Design Philosophy
|
## 1. Design Philosophy
|
||||||
|
|
||||||
GridPilot should feel like:
|
GridPilot should feel like:
|
||||||
- **a precision instrument**, not a toy
|
- **race control software**, not a game UI
|
||||||
- **a premium dashboard**, not a corporate SaaS page
|
- **infrastructure**, not a startup product
|
||||||
- **smooth and responsive**, not flashy
|
- **engineered**, not styled
|
||||||
- **crafted**, not overdesigned
|
- **stable and authoritative**, yet pleasant to use
|
||||||
- **racing-inspired**, not gamer-edgy
|
- **built for years**, not trends
|
||||||
|
|
||||||
It combines:
|
The goal is not excitement.
|
||||||
- the readability & seriousness of motorsport tools
|
The goal is **confidence**.
|
||||||
- with the soft, fluid, polished feel of a high-end app
|
|
||||||
|
|
||||||
Think:
|
Think:
|
||||||
**"iRacing x Apple UI x Motorsport telemetry aesthetics"**.
|
**“FIA race control x timing screens x modern tooling — with smooth interaction.”**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 2. Visual Style
|
## 2. Visual Style
|
||||||
|
|
||||||
### Core Aesthetic:
|
### Core Aesthetic:
|
||||||
- dark, matte background
|
- dark, neutral background
|
||||||
- soft gradients (subtle, not neon)
|
- minimal gradients (almost invisible)
|
||||||
- elegant glows only where needed
|
- restrained highlights only for meaning
|
||||||
- crisp typography
|
- strict hierarchy
|
||||||
- generous spacing
|
- dense, readable layouts
|
||||||
- smooth UI hierarchy transitions
|
- smooth transitions only where state changes
|
||||||
- layer depth through blur + shadow stacking (but tasteful)
|
|
||||||
|
|
||||||
### Color Palette:
|
Everything should look:
|
||||||
- **Deep Graphite:** `#0E0F11` (main background)
|
**intentional, measured, and calm.**
|
||||||
- **Iron Gray:** `#181B1F` (cards & panels)
|
|
||||||
- **Charcoal Outline:** `#22262A` (borders)
|
|
||||||
- **Primary Blue:** `#198CFF` (accents, active states)
|
|
||||||
- **Performance Green:** `#6FE37A` (success)
|
|
||||||
- **Warning Amber:** `#FFC556` (markers)
|
|
||||||
- **Subtle Neon Aqua:** `#43C9E6` (interactive glow effects)
|
|
||||||
|
|
||||||
Colors are **precise**, not noisy.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 3. Animation Philosophy — “Buttery Smooth, Never Distracting”
|
### Color Palette (refined)
|
||||||
|
|
||||||
Animations in GridPilot should:
|
- **Graphite Black:** `#0E0F11`
|
||||||
- feel like a **fast steering rack**: sharp + controlled
|
- **Panel Gray:** `#171A1E`
|
||||||
- feel **premium**, not “flashy”
|
- **Border Gray:** `#22262A`
|
||||||
- be **motivated**, not ornamental
|
- **Primary Accent:** `#198CFF` (used sparingly)
|
||||||
- communicate **state change** clearly
|
- **Success Green:** `#6FE37A`
|
||||||
|
- **Warning Amber:** `#FFC556`
|
||||||
|
- **Critical Red:** `#E35C5C`
|
||||||
|
|
||||||
**Animation Style:**
|
No neon.
|
||||||
- low-spring, high-damping motion
|
No playful colors.
|
||||||
- small distances, high velocity
|
Color = meaning, not decoration.
|
||||||
- micro-easing, Apple-like rebound
|
|
||||||
- intelligent inertia
|
|
||||||
- zero stutter
|
|
||||||
|
|
||||||
**Target vibe:**
|
|
||||||
> “Everything feels alive and responsive, like the UI wants to race with you.”
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 4. Where Animations Should Shine
|
## 3. Motion & Interaction
|
||||||
|
|
||||||
### ✔ Hover Interactions
|
### Animation Philosophy
|
||||||
Buttons + cards get:
|
Motion exists only to:
|
||||||
- subtle upscale (`1.0 → 1.03`)
|
- confirm an action
|
||||||
- color bloom
|
- show hierarchy
|
||||||
- ambient glow (low opacity, soft spread)
|
- indicate state change
|
||||||
|
|
||||||
### ✔ Page Transitions
|
Never to impress.
|
||||||
- fade + slide (30–50px)
|
|
||||||
- layered parallax for content panels
|
|
||||||
- 150–250ms total
|
|
||||||
|
|
||||||
Feels warm, inviting, non-static.
|
### Characteristics:
|
||||||
|
- short durations (120–200ms)
|
||||||
|
- low amplitude
|
||||||
|
- no exaggerated easing
|
||||||
|
- no elastic bounces
|
||||||
|
- no decorative movement
|
||||||
|
|
||||||
### ✔ Filters & Tabs
|
**Motion should feel like a well-damped suspension — not a show car.**
|
||||||
- sliding underline indicator
|
|
||||||
- smooth kinetic scrolling
|
|
||||||
- minimal ripple or highlight
|
|
||||||
|
|
||||||
### ✔ Dialogs & Panels
|
|
||||||
- spring pop (`scale 0.96 → 1`)
|
|
||||||
- soft drop shadow expansion
|
|
||||||
- background blur fade-in
|
|
||||||
|
|
||||||
### ✔ Table Row Expand / Collapse
|
|
||||||
- height transition: 150ms
|
|
||||||
- opacity fade-in: 120ms
|
|
||||||
- chevron rotation: 180ms
|
|
||||||
|
|
||||||
Feels like unfolding technical data — perfect for racing nerds.
|
|
||||||
|
|
||||||
### ✔ Notifications
|
|
||||||
- slide-in from top right
|
|
||||||
- friction-based deceleration
|
|
||||||
- micro-bounce at rest state
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 5. What NOT to animate
|
## 4. Where Motion Is Allowed
|
||||||
|
|
||||||
To avoid becoming “too modern = startup SaaS = untrustworthy”:
|
- button press feedback
|
||||||
|
- panel open / close
|
||||||
|
- table row expansion
|
||||||
|
- state changes (pending → approved → completed)
|
||||||
|
- subtle loading indicators
|
||||||
|
|
||||||
**No:**
|
If motion does not improve clarity → remove it.
|
||||||
- giant hero animations
|
|
||||||
- unnecessary motion in typography
|
---
|
||||||
- floating shapes / illustration wobble
|
|
||||||
- confetti / particle effects
|
## 5. What We Explicitly Avoid
|
||||||
- autoplay video backgrounds
|
|
||||||
- mobile-app style “over cute” transitions
|
- hero animations
|
||||||
|
- animated backgrounds
|
||||||
|
- glowing UI chrome
|
||||||
|
- playful hover gimmicks
|
||||||
|
- “app store” aesthetics
|
||||||
|
- anything that reduces trust
|
||||||
|
|
||||||
GridPilot must feel:
|
GridPilot must feel:
|
||||||
**professional → premium → but still understated.**
|
**reliable before it feels beautiful.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 6. Component Design Rules
|
## 6. Components
|
||||||
|
|
||||||
### Cards
|
|
||||||
- slightly rounded (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`)
|
|
||||||
|
|
||||||
### Tables
|
### Tables
|
||||||
- high-density, readable
|
- primary UI element
|
||||||
- animated sort indicators
|
- dense, readable
|
||||||
- fade-in rows on update
|
- fixed column logic
|
||||||
- highlight row on hover
|
- no playful effects
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
- functional grouping
|
||||||
|
- no visual dominance
|
||||||
|
- secondary to tables
|
||||||
|
|
||||||
### Modals
|
### Modals
|
||||||
- glassy blurred background
|
- simple
|
||||||
- smooth opening
|
- fast
|
||||||
- soft drop-shadow bloom
|
- decisive
|
||||||
- quick responsive closing
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 7. Typography
|
## 7. Typography
|
||||||
|
|
||||||
A modern, premium sans-serif:
|
- neutral sans-serif
|
||||||
- **Inter**
|
- excellent numeric readability
|
||||||
- **Roboto Flex**
|
- no personality fonts
|
||||||
- or **Plus Jakarta Sans**
|
|
||||||
|
|
||||||
Font weight:
|
Primary goal:
|
||||||
- light + regular for body
|
**information clarity, not brand expression.**
|
||||||
- semibold for headings
|
|
||||||
- numeric fields medium or monospaced (for racing aesthetics)
|
|
||||||
|
|
||||||
Typography motion:
|
|
||||||
- heading fade-in
|
|
||||||
- numeric counters animate upward subtly (60–120ms)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 8. UX Tone
|
## 8. Design Principle Summary
|
||||||
|
|
||||||
GridPilot should **feel**:
|
GridPilot is:
|
||||||
|
- not gamer UI
|
||||||
|
- not esports branding
|
||||||
|
- not corporate SaaS
|
||||||
|
|
||||||
- confident
|
It is:
|
||||||
- calm
|
**modern motorsport infrastructure software.**
|
||||||
- minimal
|
|
||||||
- smart
|
|
||||||
- “built by people who actually race”
|
|
||||||
- respectful of the 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.
|
|
||||||
220
docs/VOICE.md
220
docs/VOICE.md
@@ -1,197 +1,123 @@
|
|||||||
# GridPilot — Voice & Tone Guide
|
# GridPilot — Voice & Tone Guide
|
||||||
*A calm, clear, confident voice built for sim racers.*
|
|
||||||
|
*A calm, neutral, authoritative voice for motorsport infrastructure.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Core Personality
|
## 1. Core Personality
|
||||||
|
|
||||||
GridPilot’s voice is:
|
GridPilot is:
|
||||||
|
- calm
|
||||||
|
- neutral
|
||||||
|
- precise
|
||||||
|
- experienced
|
||||||
|
- understated
|
||||||
|
|
||||||
### **Calm**
|
We do not excite.
|
||||||
Never loud, never chaotic, never dramatic.
|
We inform.
|
||||||
|
|
||||||
### **Clear**
|
|
||||||
Short sentences. Direct meaning. Zero fluff.
|
|
||||||
|
|
||||||
### **Competent**
|
|
||||||
We sound like people who know racing and know what matters.
|
|
||||||
|
|
||||||
### **Friendly, not goofy**
|
|
||||||
Approachable and human — without memes, slang, or cringe.
|
|
||||||
|
|
||||||
### **Non-corporate**
|
|
||||||
We avoid startup jargon and “enterprise” tone completely.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. How GridPilot Sounds
|
## 2. How GridPilot Sounds
|
||||||
|
|
||||||
### **Direct**
|
### Clear
|
||||||
We make the point quickly. No fillers.
|
Short sentences.
|
||||||
|
No ambiguity.
|
||||||
|
|
||||||
**Example:**
|
### Neutral
|
||||||
“Standings updated.”
|
No opinion.
|
||||||
|
No hype.
|
||||||
|
No exaggeration.
|
||||||
|
|
||||||
### **Minimal**
|
### Competent
|
||||||
We remove unnecessary words.
|
We assume the user understands racing.
|
||||||
Writing should feel clean — like the UI.
|
|
||||||
|
|
||||||
### **Honest**
|
### Professional
|
||||||
We never overpromise or exaggerate.
|
We sound like race officials, not marketers.
|
||||||
GridPilot says what it does, nothing more.
|
|
||||||
|
|
||||||
### **Human**
|
|
||||||
Natural phrasing, like talking to another racer.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. What We Avoid
|
## 3. What We Avoid
|
||||||
|
|
||||||
### ❌ Corporate language
|
- marketing language
|
||||||
“scalable solution,” “empower,” “revolutionize,” “synergy”
|
- motivational phrasing
|
||||||
|
- emotional exaggeration
|
||||||
|
- jokes
|
||||||
|
- slang
|
||||||
|
- buzzwords
|
||||||
|
- storytelling tone
|
||||||
|
|
||||||
### ❌ Startup hype
|
GridPilot is not here to entertain.
|
||||||
“game-changing,” “disruptive,” “next-gen”
|
It is here to **operate**.
|
||||||
|
|
||||||
### ❌ Gamer slang
|
|
||||||
“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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Tone by Context
|
## 4. Tone by Context
|
||||||
|
|
||||||
### **Landing Page**
|
### UI
|
||||||
- calm
|
- direct
|
||||||
- confident
|
- factual
|
||||||
- benefit-focused
|
|
||||||
- no hype
|
|
||||||
- welcoming
|
|
||||||
|
|
||||||
Example:
|
|
||||||
“League racing should feel organized. GridPilot brings everything into one place.”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **In-App UI**
|
|
||||||
- crisp
|
|
||||||
- tool-like
|
- tool-like
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- “Season created.”
|
||||||
|
- “Driver approved.”
|
||||||
|
- “Penalty applied.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Errors
|
||||||
- neutral
|
- neutral
|
||||||
- small sentences
|
- calm
|
||||||
|
|
||||||
Examples:
|
|
||||||
“Race added.”
|
|
||||||
“Results imported.”
|
|
||||||
“Penalty applied.”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Notifications**
|
|
||||||
- non-intrusive
|
|
||||||
- soft
|
|
||||||
- clear
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
“Your next race is tomorrow.”
|
|
||||||
“Standings updated.”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Errors**
|
|
||||||
- helpful
|
|
||||||
- steady
|
|
||||||
- no blame
|
- no blame
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
“Something went wrong. Try again.”
|
- “Action failed.”
|
||||||
“Connection lost. Reconnecting…”
|
- “Invalid input.”
|
||||||
|
- “Try again.”
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### **Emails**
|
### Notifications
|
||||||
- friendly
|
|
||||||
- simple
|
|
||||||
- short
|
- short
|
||||||
|
- non-intrusive
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- “Race starts in 30 minutes.”
|
||||||
|
- “Results updated.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Emails
|
||||||
|
- factual
|
||||||
|
- brief
|
||||||
|
- no sales language
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
“Your season starts next week. Here’s the schedule.”
|
“Your season schedule has been updated.”
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Writing Style Rules
|
## 5. Writing Rules
|
||||||
|
|
||||||
### **Short sentences**
|
- remove unnecessary adjectives
|
||||||
Easy to read. Easy to scan.
|
- use active voice
|
||||||
|
- one idea per sentence
|
||||||
### **Simple verbs**
|
- no filler
|
||||||
Join, manage, view, race, update.
|
- no persuasion
|
||||||
|
|
||||||
### **Active voice**
|
|
||||||
“GridPilot updates your standings.”
|
|
||||||
Not:
|
|
||||||
“Your standings are being updated…”
|
|
||||||
|
|
||||||
### **No marketing padding**
|
|
||||||
We never pretend to be bigger than we are.
|
|
||||||
|
|
||||||
### **Every sentence should have purpose**
|
|
||||||
No filler words. No decorative language.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Phrases We Like
|
## 6. Phrases We Prefer
|
||||||
|
|
||||||
- “Everything in one place.”
|
- “All data in one place.”
|
||||||
- “Your racing identity.”
|
- “Clear standings.”
|
||||||
- “Clean standings.”
|
- “Structured seasons.”
|
||||||
|
- “Consistent rules.”
|
||||||
- “No spreadsheets.”
|
- “No spreadsheets.”
|
||||||
- “Race. We handle the rest.”
|
|
||||||
- “Built for league racing.”
|
|
||||||
- “Clear. Simple. Consistent.”
|
|
||||||
|
|
||||||
These reinforce clarity and confidence.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Phrases We Never Use
|
## 7. One-line Voice Summary
|
||||||
|
|
||||||
- “premium experience”
|
**GridPilot speaks like race control: calm, precise, and trustworthy.**
|
||||||
- “unlock your potential”
|
|
||||||
- “cutting-edge AI”
|
|
||||||
- “transform the sim racing landscape”
|
|
||||||
- “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.**
|
|
||||||
@@ -29,6 +29,7 @@ export * from './use-cases/GetLeagueStatsUseCase';
|
|||||||
export * from './use-cases/FileProtestUseCase';
|
export * from './use-cases/FileProtestUseCase';
|
||||||
export * from './use-cases/ReviewProtestUseCase';
|
export * from './use-cases/ReviewProtestUseCase';
|
||||||
export * from './use-cases/ApplyPenaltyUseCase';
|
export * from './use-cases/ApplyPenaltyUseCase';
|
||||||
|
export * from './use-cases/QuickPenaltyUseCase';
|
||||||
export * from './use-cases/GetRaceProtestsUseCase';
|
export * from './use-cases/GetRaceProtestsUseCase';
|
||||||
export * from './use-cases/GetRacePenaltiesUseCase';
|
export * from './use-cases/GetRacePenaltiesUseCase';
|
||||||
export * from './use-cases/RequestProtestDefenseUseCase';
|
export * from './use-cases/RequestProtestDefenseUseCase';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||||
|
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||||
import { Result } from '../../domain/entities/Result';
|
import { Result } from '../../domain/entities/Result';
|
||||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||||
@@ -35,6 +36,7 @@ export class ImportRaceResultsUseCase
|
|||||||
private readonly raceRepository: IRaceRepository,
|
private readonly raceRepository: IRaceRepository,
|
||||||
private readonly leagueRepository: ILeagueRepository,
|
private readonly leagueRepository: ILeagueRepository,
|
||||||
private readonly resultRepository: IResultRepository,
|
private readonly resultRepository: IResultRepository,
|
||||||
|
private readonly driverRepository: IDriverRepository,
|
||||||
private readonly standingRepository: IStandingRepository,
|
private readonly standingRepository: IStandingRepository,
|
||||||
public readonly presenter: IImportRaceResultsPresenter,
|
public readonly presenter: IImportRaceResultsPresenter,
|
||||||
) {}
|
) {}
|
||||||
@@ -57,15 +59,22 @@ export class ImportRaceResultsUseCase
|
|||||||
throw new BusinessRuleViolationError('Results already exist for this race');
|
throw new BusinessRuleViolationError('Results already exist for this race');
|
||||||
}
|
}
|
||||||
|
|
||||||
const entities = results.map((dto) =>
|
// Lookup drivers by iracingId and create results with driver.id
|
||||||
Result.create({
|
const entities = await Promise.all(
|
||||||
id: dto.id,
|
results.map(async (dto) => {
|
||||||
raceId: dto.raceId,
|
const driver = await this.driverRepository.findByIRacingId(dto.driverId);
|
||||||
driverId: dto.driverId,
|
if (!driver) {
|
||||||
position: dto.position,
|
throw new BusinessRuleViolationError(`Driver with iRacing ID ${dto.driverId} not found`);
|
||||||
fastestLap: dto.fastestLap,
|
}
|
||||||
incidents: dto.incidents,
|
return Result.create({
|
||||||
startPosition: dto.startPosition,
|
id: dto.id,
|
||||||
|
raceId: dto.raceId,
|
||||||
|
driverId: driver.id,
|
||||||
|
position: dto.position,
|
||||||
|
fastestLap: dto.fastestLap,
|
||||||
|
incidents: dto.incidents,
|
||||||
|
startPosition: dto.startPosition,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
BIN
race-results-page.png
Normal file
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 presenter = new FakeImportRaceResultsPresenter();
|
||||||
|
|
||||||
|
const driverRepository = {
|
||||||
|
findById: async (): Promise<Driver | null> => null,
|
||||||
|
findByIRacingId: async (iracingId: string): Promise<Driver | null> => {
|
||||||
|
// Mock finding driver by iracingId
|
||||||
|
if (iracingId === 'driver-1') {
|
||||||
|
return Driver.create({ id: 'driver-1', iracingId: 'driver-1', name: 'Driver One', country: 'US' });
|
||||||
|
}
|
||||||
|
if (iracingId === 'driver-2') {
|
||||||
|
return Driver.create({ id: 'driver-2', iracingId: 'driver-2', name: 'Driver Two', country: 'GB' });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
findAll: async (): Promise<Driver[]> => [],
|
||||||
|
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||||
|
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
|
||||||
|
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||||
|
exists: async (): Promise<boolean> => false,
|
||||||
|
existsByIRacingId: async (): Promise<boolean> => false,
|
||||||
|
};
|
||||||
|
|
||||||
const useCase = new ImportRaceResultsUseCase(
|
const useCase = new ImportRaceResultsUseCase(
|
||||||
raceRepository,
|
raceRepository,
|
||||||
leagueRepository,
|
leagueRepository,
|
||||||
resultRepository,
|
resultRepository,
|
||||||
|
driverRepository,
|
||||||
standingRepository,
|
standingRepository,
|
||||||
presenter,
|
presenter,
|
||||||
);
|
);
|
||||||
|
|||||||
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