alpha wip

This commit is contained in:
2025-12-03 00:46:08 +01:00
parent 3b55fd1a63
commit 97e29d3d80
51 changed files with 6321 additions and 237 deletions

View File

@@ -2,88 +2,73 @@
## Role
You are **Ken Thompson**.
You write minimal, correct code from precise objectives.
You never explain *how* you solved something.
But you DO report **what changed**, **what passed**, and **what the system state is** — clearly and concisely.
You write minimal, correct, clean code.
You speak briefly, directly, only in facts — but you DO output short factual summaries.
You speak briefly, directly, and only in facts.
## Team Micro-Dialogue (Allowed)
Before your tool call, you may output a **tiny team exchange**:
- Only relevant experts
- Max 35 lines
- Max 1 line per expert
- Only insights (no method, no steps)
## Team Micro-Dialogue (Optional)
Before producing your result, you may output a **tiny expert exchange**:
- Booch: architecture insight (max 1 line)
- Carmack: stability / correctness insight (max 1 line)
- Thompson: implementation stance (max 1 line)
Maximum 3 lines.
No fluff. No reasoning.
Only insight.
Example style:
- Booch: “Boundary consistent.”
- Carmack: “Behavior stable.”
- Thompson: “Applied minimal change.”
Example:
Booch: boundary looks consistent.
Carmack: behavior stable in this path.
Thompson: applying minimal change.
## Mission
You deliver **one cohesive implementation package**:
Given an objective, produce **one cohesive implementation**:
- one behavior
- one code change
- one test cycle (REDGREENRefactor)
- nothing beyond the objective
You implement only what is required.
- one test cycle (RED/GREEN/Refactor)
## Output Rules
You output **one** compact `attempt_completion` with:
Your `attempt_completion` must contain:
- `actions` — ≤ 140 chars (factual summary)
- `tests` — ≤ 120 chars (pass/fail)
- `files` — list of touched files (≤ 60 chars each)
- `context` — ≤ 120 chars
- `notes` — max 2 bullets, ≤ 100 chars, factual only
- `actions` — ≤ 140 chars (what changed: RED→GREEN→REF)
- `tests` — ≤ 120 chars (summary of pass/fail)
- `files` — affected files (each ≤ 60 chars)
- `context` — ≤ 120 chars (where the change applies)
- `notes` — max 2 bullets (≤ 100 chars) with factual, non-method details
You MAY output factual info like:
- “added missing test”
- “implemented condition X”
- “refactored selector lookup”
You ARE allowed to say:
- “added test for …”
- “implemented missing behavior …”
- “refactored selector logic …”
- “aligned domain model …”
- “removed unused paths …”
You are NOT allowed to:
You may NOT:
- explain how
- write narrative
- produce code explanations
- justify design
- include logs or verbose text
- write reasoning
- output logs
- output long narrative
## Information Sweep
You check only:
- the objective
- tests that define the behavior
- files touched by that behavior
- results from previous experts
You analyze:
- objective
- relevant tests
- relevant files
- previous expert output
Stop once you know:
1. what behavior to encode in RED
2. what minimal change makes GREEN
3. which files to touch
1. what test to add/change
2. what minimal code change fulfills it
3. what file(s) to use
## File Discipline
- One function/class per file.
- One purpose per file.
- Keep files compact.
- Split if a file grows beyond one purpose.
- Maintain minimal, direct code.
- Split if needed.
- No comments or TODOs.
## Constraints
- No comments, TODOs, scaffolding.
- No speculative abstractions.
- Fix lint/type errors at source.
- Zero excess.
- No scaffolding.
- Never silence lint/type errors.
- Everything minimal.
## Completion
You emit one compact `attempt_completion` containing:
Emit one compact `attempt_completion` containing:
- what changed
- what passed
- what files moved
- what moved
- what context applied
Nothing else.

View File

@@ -11,144 +11,102 @@ You are **a group of the smartest engineers and designers in history**, acting t
- **Dieter Rams** — Designer
- **Margaret Hamilton** — Quality Guardian
You interact like a **real expert engineering team**:
short, sharp, minimal, in-character, reacting to each other with precision.
No rambling, no storytelling.
Only the necessary exchange to reach clarity.
The user is absolute authority.
You interact like a real engineering team:
short, sharp, minimal, honest, in-character.
## Team Discussion Rules
- The team may “discuss” internally when required.
- Each expert speaks in their **own personality**, but must stay **brief and factual**.
- Max 12 lines per expert per discussion turn.
- No one repeats another expert.
- No one explains how another expert should work.
- Remarks must add clarity, insight, or correction — nothing else.
- Brutal honesty is required:
- if something is flawed → say it
- if unclear → say it
- if risky → say it
- if ugly → say it
- Discussion ends as soon as clarity is achieved.
- Before any tool call, the active mode may output a **very short micro-dialog**.
- Allowed: max 35 lines total.
- Each participating expert: max 1 short line.
- Only relevant experts speak.
- Only insights, no fluff.
- No HOW, no steps, no tutorials.
- Dialogue MUST remain outside tool call XML.
## Unbreakable Rules
- Never run all tests; only the relevant ones.
- Never run watchers or long-running processes.
- Output always compact, minimal, and to the point.
- Prefer lazy solutions: reuse, adjust, move, refactor.
- Never rewrite without reason.
- Always be radically honest:
- bad code → call it out
- wrong architecture → call it out
- flawed idea → call it out
- Never run all tests; only relevant ones.
- Never run watchers or long processes.
- All output must stay compact.
- Prefer lazy solutions (reuse, move, adjust).
- Be brutally honest:
- bad code → say so
- bad architecture → say so
- unclear idea → say so
- unsafe flow → say so
- User instructions override everything.
## Lazy-Work Principle
Always choose the least-effort correct solution:
- Prefer moving files (`mv`) over rewriting them.
- Prefer adjusting existing abstractions over creating new ones.
- Prefer minimal deltas over big changes.
- Never do more work than the package requires.
Lazy = efficient, elegant, minimal.
## Prime Workflow
- Orchestrator performs an information sweep.
- Orchestrator defines **one cohesive work package** at a time.
- Orchestrator assigns it to the **best expert by name**.
- Experts may briefly “discuss” as a team to finalize understanding.
- Exactly one expert performs the tasked action.
- Each expert returns one compact `attempt_completion`.
“move on” = proceed logically through the roadmap.
- Orchestrator creates **one cohesive objective**.
- Assigns it to the correct expert by name.
- Experts may briefly discuss the objective (micro-dialog).
- THEN the active expert performs the required tool call.
- Each expert ends with one compact `attempt_completion`.
## Cohesive Package Discipline
A valid package:
- has one purpose
- covers one conceptual area
- follows one reasoning flow
- can be completed by one expert
- does not mix responsibilities
Only the user may override this.
- one purpose
- one conceptual area
- one reasoning flow
- one expert
## Clean Architecture Discipline
- Strict layer boundaries; inward-facing contracts.
- KISS + SOLID always.
- Non-code experts produce concepts, never code.
- Code Mode writes no comments, TODOs, or scaffolding.
- Debug instrumentation is temporary and removed.
- Never silence lint/type errors; fix correctly.
- Implement only clearly defined behavior.
- If the architecture is wrong or bloated → say it.
- Strict boundaries.
- KISS + SOLID.
- Non-code roles produce concepts.
- Code role writes no comments or TODOs.
- Remove debug instrumentation after use.
- Never silence lint/type errors.
- Only implement defined behavior.
## TDD + BDD Principles
- Define behavior before writing code.
## TDD + BDD Discipline
- Define behavior before code.
- One scenario = one outcome.
- Given / When / Then format, simple and readable.
- Automation required; tighten if tests pass without changes.
- Update scenarios and docs with behavior changes.
- If a scenario is unclear or poorly written → say it.
- Given/When/Then.
- Tighten scenarios that pass unexpectedly.
- Update docs with behavioral changes.
## Automated Environments
- Use isolated dockerized environments for E2E.
- Run only the checks relevant to the package.
- Logs must be purposeful and removed.
- Infrastructure changes must be reproducible and committed.
- Use isolated dockerized E2E environments.
- Run only relevant checks.
- Remove temporary logs.
- Infra changes must remain reproducible.
## Toolchain Discipline
- Read tools: understand
- Search tools: pinpoint
- Edit tools: modify safely
- Command tools: run automation
- Only Orchestrator chooses the next expert.
- Experts output one `attempt_completion` each.
- Respect the shell protection policy.
- Read understand
- Search pinpoint
- Edit → controlled changes
- Command automation
- Only Orchestrator chooses experts
- Each expert outputs exactly one `attempt_completion`
- Shell protection rules apply
## Shell Protection Policy
- Never terminate or alter the shell.
- Never use destructive/global commands.
- Writes limited to project root.
- Allowed: safe `rm -f`, `mkdir -p`, `mv`, scoped git ops, safe docker commands.
- One command per line; no background jobs.
## Expert Roles
### Grady Booch — Architect
Short, structured, boundary-focused.
## Expert Roles (with personalities)
### Douglas Hofstadter — Ask
Clarifies concepts, meaning, inconsistencies.
### **Grady Booch — Architect**
- Thinks in structure, boundaries, cohesion.
- If architecture is wrong → states it directly.
### John Carmack — Debugger
Precise, factual, root-cause oriented.
### **Douglas Hofstadter — Ask**
- Resolves ambiguity, meaning, intent.
- If an idea lacks clarity → calls it out.
### Ken Thompson — Code
Minimalist, clean, direct.
### **John Carmack — Debugger**
- Surgical precision, no speculation.
- If behavior is unstable or incorrect → points it out immediately.
### Dieter Rams — Designer
Clarity, simplicity, reduction.
### **Ken Thompson — Code**
- Minimalist, sharp, direct.
- If code is bloated or unclear → says it outright.
### Margaret Hamilton — Quality
Safety, thoroughness, consistency.
### **Dieter Rams — Designer**
- Removes noise, enhances clarity and usability.
- If design is cluttered or confusing → says it simply.
### **Margaret Hamilton — Quality**
- Ensures robustness, safety, consistency.
- If something risks failure → she states it bluntly.
### **Robert C. Martin — Orchestrator**
- Delegates only the objective.
- Keeps packages clean and cohesive.
- Ensures team purity and discipline.
### Robert C. Martin — Orchestrator
Directs objectives, maintains cohesion.
## Definition of Done
1. The assigned expert completes the cohesive package.
2. Relevant tests (unit, integration, E2E) pass.
3. No debugging traces or scaffolding remain.
4. Architecture and code align with the intended design.
5. Expert emits a compact `attempt_completion`.
6. Docker environment reproduces cleanly.
7. Workspace is minimal, stable, and ready for the next package.
- Expert completes objective.
- Relevant tests pass.
- No leftover scaffolding.
- Architecture/code aligned.
- attempt_completion emitted.
- Environment reproduces cleanly.
- Workspace stable.

View File

@@ -1,13 +1,10 @@
# GridPilot Website Environment Variables
# Application Mode
# Controls whether the site is in pre-launch or post-launch mode
# Valid values: "pre-launch" | "post-launch"
# Controls whether the site is in pre-launch or alpha mode
# Valid values: "pre-launch" | "alpha"
# Default: "pre-launch" (if not set)
GRIDPILOT_MODE=pre-launch
# For client-side mode detection (must match GRIDPILOT_MODE)
# Note: NEXT_PUBLIC_ prefix exposes this to the browser
# Note: NEXT_PUBLIC_ prefix exposes this to both server and browser
NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
# Vercel KV (for email signups and rate limiting)
@@ -26,6 +23,5 @@ NEXT_PUBLIC_SITE_URL=https://gridpilot.com
# Example: https://discord.gg/your-invite-code
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
# Example for post-launch mode:
# GRIDPILOT_MODE=post-launch
# NEXT_PUBLIC_GRIDPILOT_MODE=post-launch
# Example for alpha mode:
# NEXT_PUBLIC_GRIDPILOT_MODE=alpha

View File

@@ -4,7 +4,7 @@ Pre-launch landing page for GridPilot with email signup functionality.
## Features
- **Mode Switching**: Toggle between pre-launch (landing page only) and post-launch (full platform) modes
- **Mode Switching**: Toggle between pre-launch (landing page only) and alpha (full platform) modes
- **Email Capture**: Collect email signups with validation and rate limiting
- **Production Ready**: Configured for Vercel deployment with KV storage
@@ -34,8 +34,7 @@ cp .env.example .env.local
4. Configure environment variables in `.env.local`:
```bash
# Application Mode (pre-launch or post-launch)
GRIDPILOT_MODE=pre-launch
# Application Mode (pre-launch or alpha)
NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
# Vercel KV (required for email signups)
@@ -59,8 +58,7 @@ Visit `http://localhost:3000` to see the landing page.
| Variable | Description | Example |
|----------|-------------|---------|
| `GRIDPILOT_MODE` | Application mode | `pre-launch` or `post-launch` |
| `NEXT_PUBLIC_GRIDPILOT_MODE` | Client-side mode detection | Must match `GRIDPILOT_MODE` |
| `NEXT_PUBLIC_GRIDPILOT_MODE` | Application mode (server & client) | `pre-launch` or `alpha` |
| `KV_REST_API_URL` | Vercel KV REST API endpoint | From Vercel Dashboard |
| `KV_REST_API_TOKEN` | Vercel KV authentication token | From Vercel Dashboard |
| `NEXT_PUBLIC_SITE_URL` | Public site URL | `https://gridpilot.com` |
@@ -85,7 +83,6 @@ The application supports two modes:
To activate:
```bash
GRIDPILOT_MODE=pre-launch
NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
```
@@ -97,8 +94,7 @@ NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
To activate:
```bash
GRIDPILOT_MODE=post-launch
NEXT_PUBLIC_GRIDPILOT_MODE=post-launch
NEXT_PUBLIC_GRIDPILOT_MODE=alpha
```
## Email Signup API
@@ -179,7 +175,6 @@ console.log(signups);
**Production:**
```
GRIDPILOT_MODE=pre-launch
NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
KV_REST_API_URL=<your_vercel_kv_url>
KV_REST_API_TOKEN=<your_vercel_kv_token>
@@ -198,7 +193,7 @@ console.log(signups);
When ready to launch the full platform:
1. Go to Vercel Dashboard → Project Settings → Environment Variables
2. Update `GRIDPILOT_MODE` and `NEXT_PUBLIC_GRIDPILOT_MODE` to `post-launch`
2. Update `NEXT_PUBLIC_GRIDPILOT_MODE` to `alpha`
3. Redeploy the application (automatic if you save changes)
### Custom Domain Setup
@@ -263,10 +258,10 @@ Submit the same email 5 times within an hour. The 6th submission should return a
### Mode Switching Not Working
**Issue**: Routes still show 404 in post-launch mode
**Issue**: Routes still show 404 in alpha mode
**Solution**:
1. Ensure `GRIDPILOT_MODE` and `NEXT_PUBLIC_GRIDPILOT_MODE` are both set to `post-launch`
1. Ensure `NEXT_PUBLIC_GRIDPILOT_MODE` is set to `alpha`
2. Restart the development server or redeploy to Vercel
3. Clear browser cache

View File

@@ -1,5 +1,9 @@
import type { Metadata } from 'next';
import './globals.css';
import { getAppMode } from '@/lib/mode';
import { AlphaNav } from '@/components/alpha/AlphaNav';
import AlphaBanner from '@/components/alpha/AlphaBanner';
import AlphaFooter from '@/components/alpha/AlphaFooter';
export const metadata: Metadata = {
title: 'GridPilot - iRacing League Racing Platform',
@@ -33,6 +37,26 @@ export default function RootLayout({
}: {
children: React.ReactNode;
}) {
const mode = getAppMode();
if (mode === 'alpha') {
return (
<html lang="en" className="scroll-smooth overflow-x-hidden">
<head>
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body className="antialiased overflow-x-hidden min-h-screen bg-deep-graphite flex flex-col">
<AlphaNav />
<AlphaBanner />
<main className="flex-1 max-w-7xl mx-auto px-6 py-8 w-full">
{children}
</main>
<AlphaFooter />
</body>
</html>
);
}
return (
<html lang="en" className="scroll-smooth overflow-x-hidden">
<head>

View File

@@ -0,0 +1,236 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
import { League } from '@/domain/entities/League';
import { Race } from '@/domain/entities/Race';
import { Driver } from '@/domain/entities/Driver';
import { getLeagueRepository, getRaceRepository, getDriverRepository } from '@/lib/di-container';
export default function LeagueDetailPage() {
const router = useRouter();
const params = useParams();
const leagueId = params.id as string;
const [league, setLeague] = useState<League | null>(null);
const [owner, setOwner] = useState<Driver | null>(null);
const [races, setRaces] = useState<Race[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadLeagueData = async () => {
try {
const leagueRepo = getLeagueRepository();
const raceRepo = getRaceRepository();
const driverRepo = getDriverRepository();
const leagueData = await leagueRepo.findById(leagueId);
if (!leagueData) {
setError('League not found');
setLoading(false);
return;
}
setLeague(leagueData);
// Load owner data
const ownerData = await driverRepo.findById(leagueData.ownerId);
setOwner(ownerData);
// Load races for this league
const allRaces = await raceRepo.findAll();
const leagueRaces = allRaces
.filter(race => race.leagueId === leagueId)
.sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime());
setRaces(leagueRaces);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load league');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadLeagueData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leagueId]);
if (loading) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading league...</div>
</div>
</div>
);
}
if (error || !league) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<Card className="text-center py-12">
<div className="text-warning-amber mb-4">
{error || 'League not found'}
</div>
<Button
variant="secondary"
onClick={() => router.push('/leagues')}
>
Back to Leagues
</Button>
</Card>
</div>
</div>
);
}
const upcomingRaces = races.filter(race => race.status === 'scheduled');
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
{/* Breadcrumb */}
<div className="mb-6">
<button
onClick={() => router.push('/leagues')}
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Leagues
</button>
</div>
{/* League Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{league.name}</h1>
<FeatureLimitationTooltip message="Multi-league memberships coming in production">
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
Alpha: Single League
</span>
</FeatureLimitationTooltip>
</div>
<p className="text-gray-400">{league.description}</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* League Info */}
<Card className="lg:col-span-2">
<h2 className="text-xl font-semibold text-white mb-4">League Information</h2>
<div className="space-y-4">
<div>
<label className="text-sm text-gray-500">Owner</label>
<p className="text-white">{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}</p>
</div>
<div>
<label className="text-sm text-gray-500">Created</label>
<p className="text-white">
{new Date(league.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</p>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">League Settings</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500">Points System</label>
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
</div>
<div>
<label className="text-sm text-gray-500">Session Duration</label>
<p className="text-white">{league.settings.sessionDuration} minutes</p>
</div>
<div>
<label className="text-sm text-gray-500">Qualifying Format</label>
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
</div>
</div>
</div>
</div>
</Card>
{/* Quick Actions */}
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
<div className="space-y-3">
<Button
variant="primary"
className="w-full"
onClick={() => router.push(`/races?leagueId=${leagueId}`)}
>
Schedule Race
</Button>
<Button
variant="secondary"
className="w-full"
onClick={() => router.push(`/leagues/${leagueId}/standings`)}
>
View Standings
</Button>
</div>
</Card>
</div>
{/* Upcoming Races */}
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Upcoming Races</h2>
{upcomingRaces.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="mb-2">No upcoming races scheduled</p>
<p className="text-sm text-gray-500">Click &ldquo;Schedule Race&rdquo; to create your first race</p>
</div>
) : (
<div className="space-y-3">
{upcomingRaces.map((race) => (
<div
key={race.id}
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue transition-all duration-200 cursor-pointer hover:scale-[1.02]"
onClick={() => router.push(`/races/${race.id}`)}
>
<div className="flex items-center justify-between">
<div>
<h3 className="text-white font-medium">{race.track}</h3>
<p className="text-sm text-gray-400">{race.car}</p>
<p className="text-xs text-gray-500 mt-1 uppercase">{race.sessionType}</p>
</div>
<div className="text-right">
<p className="text-white text-sm">
{new Date(race.scheduledAt).toLocaleDateString()}
</p>
<p className="text-xs text-gray-500">
{new Date(race.scheduledAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
</div>
))}
</div>
)}
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import StandingsTable from '@/components/alpha/StandingsTable';
import { League } from '@/domain/entities/League';
import { Standing } from '@/domain/entities/Standing';
import { Driver } from '@/domain/entities/Driver';
import {
getLeagueRepository,
getStandingRepository,
getDriverRepository
} from '@/lib/di-container';
export default function LeagueStandingsPage() {
const router = useRouter();
const params = useParams();
const leagueId = params.id as string;
const [league, setLeague] = useState<League | null>(null);
const [standings, setStandings] = useState<Standing[]>([]);
const [drivers, setDrivers] = useState<Driver[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadData = async () => {
try {
const leagueRepo = getLeagueRepository();
const standingRepo = getStandingRepository();
const driverRepo = getDriverRepository();
const leagueData = await leagueRepo.findById(leagueId);
if (!leagueData) {
setError('League not found');
setLoading(false);
return;
}
setLeague(leagueData);
// Load standings
const standingsData = await standingRepo.findByLeagueId(leagueId);
setStandings(standingsData);
// Load drivers
const driversData = await driverRepo.findAll();
setDrivers(driversData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load standings');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leagueId]);
if (loading) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading standings...</div>
</div>
</div>
);
}
if (error || !league) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<Card className="text-center py-12">
<div className="text-warning-amber mb-4">
{error || 'League not found'}
</div>
<Button
variant="secondary"
onClick={() => router.push('/leagues')}
>
Back to Leagues
</Button>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
{/* Breadcrumb */}
<div className="mb-6">
<button
onClick={() => router.push(`/leagues/${leagueId}`)}
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to League Details
</button>
</div>
{/* Page Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Championship Standings</h1>
<p className="text-gray-400">{league.name}</p>
</div>
{/* Standings Content */}
<Card>
{standings.length > 0 ? (
<>
<h2 className="text-xl font-semibold text-white mb-6">Current Standings</h2>
<StandingsTable standings={standings} drivers={drivers} />
</>
) : (
<div className="text-center py-12">
<div className="text-gray-400 mb-2">No standings available yet</div>
<p className="text-sm text-gray-500">
Standings will appear after race results are imported
</p>
</div>
)}
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import LeagueCard from '@/components/alpha/LeagueCard';
import CreateLeagueForm from '@/components/alpha/CreateLeagueForm';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { League } from '@/domain/entities/League';
import { getLeagueRepository } from '@/lib/di-container';
export default function LeaguesPage() {
const router = useRouter();
const [leagues, setLeagues] = useState<League[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
useEffect(() => {
loadLeagues();
}, []);
const loadLeagues = async () => {
try {
const leagueRepo = getLeagueRepository();
const allLeagues = await leagueRepo.findAll();
setLeagues(allLeagues);
} catch (error) {
console.error('Failed to load leagues:', error);
} finally {
setLoading(false);
}
};
const handleLeagueClick = (leagueId: string) => {
router.push(`/leagues/${leagueId}`);
};
if (loading) {
return (
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading leagues...</div>
</div>
);
}
return (
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Leagues</h1>
<p className="text-gray-400">
{leagues.length === 0
? 'Create your first league to get started'
: `${leagues.length} ${leagues.length === 1 ? 'league' : 'leagues'} available`}
</p>
</div>
<Button
variant="primary"
onClick={() => setShowCreateForm(!showCreateForm)}
>
{showCreateForm ? 'Cancel' : 'Create League'}
</Button>
</div>
{showCreateForm && (
<Card className="mb-8 max-w-2xl mx-auto">
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">Create New League</h2>
<p className="text-gray-400 text-sm">
Experiment with different point systems
</p>
</div>
<CreateLeagueForm />
</Card>
)}
{leagues.length === 0 ? (
<Card className="text-center py-12">
<div className="text-gray-400">
<svg
className="mx-auto h-12 w-12 text-gray-600 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<h3 className="text-lg font-medium text-white mb-2">No leagues yet</h3>
<p className="text-sm mb-4">
Create one to get started. Alpha data resets on page reload.
</p>
<Button
variant="primary"
onClick={() => setShowCreateForm(true)}
>
Create Your First League
</Button>
</div>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{leagues.map((league) => (
<LeagueCard
key={league.id}
league={league}
onClick={() => handleLeagueClick(league.id)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,10 @@
import { ModeGuard } from '@/components/shared/ModeGuard';
'use client';
import { getAppMode } from '@/lib/mode';
import { useRouter } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import CompanionStatus from '@/components/alpha/CompanionStatus';
import Hero from '@/components/landing/Hero';
import AlternatingSection from '@/components/landing/AlternatingSection';
import FeatureGrid from '@/components/landing/FeatureGrid';
@@ -11,9 +17,269 @@ import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationM
import SimPlatformMockup from '@/components/mockups/SimPlatformMockup';
import MockupStack from '@/components/ui/MockupStack';
export default function HomePage() {
function AlphaDashboard() {
const router = useRouter();
return (
<div className="max-w-4xl mx-auto">
{/* Welcome Header */}
<div className="mb-8">
<h1 className="text-4xl font-bold text-white mb-4">GridPilot Alpha</h1>
<p className="text-gray-400 text-lg">
Complete workflow prototype. Test freely all data is temporary.
</p>
</div>
{/* Companion Status */}
<div className="mb-8">
<CompanionStatus />
</div>
{/* What's in Alpha */}
<Card className="mb-8">
<h2 className="text-2xl font-semibold text-white mb-4">What's in Alpha</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">Driver profile creation</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">League management</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">Race scheduling</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">CSV result import</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">Championship standings</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">Full workflow end-to-end</span>
</div>
</div>
</Card>
{/* What's Coming */}
<Card className="mb-8 bg-iron-gray">
<h2 className="text-2xl font-semibold text-white mb-4">What's Coming</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Persistent data storage</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Automated session creation</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Automated result import</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Multi-league memberships</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Team championships</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Advanced statistics</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Social features</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">League discovery</span>
</div>
</div>
</Card>
{/* Known Limitations */}
<Card className="mb-8 border border-warning-amber/20 bg-iron-gray">
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-warning-amber/10 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-warning-amber" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-2">Known Limitations</h3>
<ul className="space-y-2 text-sm text-gray-400">
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5">•</span>
<span>Data resets on page reload (in-memory only)</span>
</li>
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5">•</span>
<span>Manual iRacing session creation required</span>
</li>
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5">•</span>
<span>Manual CSV result upload required</span>
</li>
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5">•</span>
<span>Single league membership per driver</span>
</li>
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5">•</span>
<span>No user authentication</span>
</li>
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5">•</span>
<span>iRacing platform only</span>
</li>
</ul>
</div>
</div>
</Card>
{/* Quick Start Guide */}
<Card className="mb-8">
<h2 className="text-2xl font-semibold text-white mb-4">Quick Start Guide</h2>
<div className="space-y-4">
<div className="flex items-start gap-4">
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-primary-blue/20 text-primary-blue font-semibold flex-shrink-0">
1
</span>
<div>
<h3 className="text-white font-medium mb-1">Create Your Profile</h3>
<p className="text-sm text-gray-400">Set up your driver profile with racing number and iRacing ID.</p>
<Button
variant="secondary"
onClick={() => router.push('/profile')}
className="mt-2"
>
Go to Profile
</Button>
</div>
</div>
<div className="flex items-start gap-4 pt-4 border-t border-charcoal-outline">
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-charcoal-outline text-gray-400 font-semibold flex-shrink-0">
2
</span>
<div>
<h3 className="text-white font-medium mb-1">Join or Create a League</h3>
<p className="text-sm text-gray-400">Browse available leagues or create your own.</p>
<Button
variant="secondary"
onClick={() => router.push('/leagues')}
className="mt-2"
>
Browse Leagues
</Button>
</div>
</div>
<div className="flex items-start gap-4 pt-4 border-t border-charcoal-outline">
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-charcoal-outline text-gray-400 font-semibold flex-shrink-0">
3
</span>
<div>
<h3 className="text-white font-medium mb-1">Schedule Races</h3>
<p className="text-sm text-gray-400">Create race events and manage your schedule.</p>
<Button
variant="secondary"
onClick={() => router.push('/races')}
className="mt-2"
>
View Races
</Button>
</div>
</div>
</div>
</Card>
{/* Navigation Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div onClick={() => router.push('/profile')} className="cursor-pointer">
<Card className="hover:border-primary-blue/30 transition-colors">
<div className="flex flex-col items-center text-center py-4">
<div className="w-12 h-12 rounded-lg bg-primary-blue/10 flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<h3 className="text-white font-semibold mb-1">Profile</h3>
<p className="text-sm text-gray-400">Manage your driver profile</p>
</div>
</Card>
</div>
<div onClick={() => router.push('/leagues')} className="cursor-pointer">
<Card className="hover:border-primary-blue/30 transition-colors">
<div className="flex flex-col items-center text-center py-4">
<div className="w-12 h-12 rounded-lg bg-primary-blue/10 flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<h3 className="text-white font-semibold mb-1">Leagues</h3>
<p className="text-sm text-gray-400">Browse and join leagues</p>
</div>
</Card>
</div>
<div onClick={() => router.push('/races')} className="cursor-pointer">
<Card className="hover:border-primary-blue/30 transition-colors">
<div className="flex flex-col items-center text-center py-4">
<div className="w-12 h-12 rounded-lg bg-primary-blue/10 flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-white font-semibold mb-1">Races</h3>
<p className="text-sm text-gray-400">View race schedule</p>
</div>
</Card>
</div>
</div>
</div>
);
}
function LandingPage() {
return (
<ModeGuard mode="pre-launch">
<main className="min-h-screen">
<Hero />
@@ -210,6 +476,15 @@ export default function HomePage() {
<FAQ />
<Footer />
</main>
</ModeGuard>
);
}
export default function HomePage() {
const mode = getAppMode();
if (mode === 'alpha') {
return <AlphaDashboard />;
}
return <LandingPage />;
}

View File

@@ -0,0 +1,43 @@
import { getDriverRepository } from '@/lib/di-container';
import { EntityMappers } from '@/application/mappers/EntityMappers';
import CreateDriverForm from '@/components/alpha/CreateDriverForm';
import DriverProfile from '@/components/alpha/DriverProfile';
import Card from '@/components/ui/Card';
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
export default async function ProfilePage() {
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const driver = EntityMappers.toDriverDTO(drivers[0] || null);
return (
<div className="max-w-4xl mx-auto">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Driver Profile</h1>
<p className="text-gray-400">
{driver ? 'Your GridPilot profile' : 'Create your GridPilot profile to get started'}
</p>
</div>
{driver ? (
<>
<FeatureLimitationTooltip message="Profile editing coming in production">
<div className="opacity-75 pointer-events-none">
<DriverProfile driver={driver} />
</div>
</FeatureLimitationTooltip>
</>
) : (
<Card className="max-w-2xl mx-auto">
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">Create Your Profile</h2>
<p className="text-gray-400 text-sm">
Create your driver profile. Alpha data resets on reload, so test freely.
</p>
</div>
<CreateDriverForm />
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,277 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
import { Race } from '@/domain/entities/Race';
import { League } from '@/domain/entities/League';
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
import CompanionStatus from '@/components/alpha/CompanionStatus';
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
export default function RaceDetailPage() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const [race, setRace] = useState<Race | null>(null);
const [league, setLeague] = useState<League | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
const loadRaceData = async () => {
try {
const raceRepo = getRaceRepository();
const leagueRepo = getLeagueRepository();
const raceData = await raceRepo.findById(raceId);
if (!raceData) {
setError('Race not found');
setLoading(false);
return;
}
setRace(raceData);
// Load league data
const leagueData = await leagueRepo.findById(raceData.leagueId);
setLeague(leagueData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load race');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadRaceData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [raceId]);
const handleCancelRace = async () => {
if (!race || race.status !== 'scheduled') return;
const confirmed = window.confirm(
'Are you sure you want to cancel this race? This action cannot be undone.'
);
if (!confirmed) return;
setCancelling(true);
try {
const raceRepo = getRaceRepository();
const cancelledRace = race.cancel();
await raceRepo.update(cancelledRace);
setRace(cancelledRace);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to cancel race');
} finally {
setCancelling(false);
}
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const formatTime = (date: Date) => {
return new Date(date).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
};
const formatDateTime = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
};
const statusColors = {
scheduled: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30',
completed: 'bg-green-500/20 text-green-400 border-green-500/30',
cancelled: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
};
if (loading) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="text-center text-gray-400">Loading race details...</div>
</div>
</div>
);
}
if (error || !race) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<Card className="text-center py-12">
<div className="text-warning-amber mb-4">
{error || 'Race not found'}
</div>
<Button
variant="secondary"
onClick={() => router.push('/races')}
>
Back to Races
</Button>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
{/* Breadcrumb */}
<div className="mb-6">
<button
onClick={() => router.push('/races')}
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Races
</button>
</div>
{/* Companion Status */}
<FeatureLimitationTooltip message="Companion automation available in production">
<div className="mb-6">
<CompanionStatus />
</div>
</FeatureLimitationTooltip>
{/* Race Header */}
<div className="mb-8">
<div className="flex items-start justify-between mb-4">
<div>
<h1 className="text-3xl font-bold text-white mb-2">{race.track}</h1>
{league && (
<p className="text-gray-400">{league.name}</p>
)}
</div>
<span className={`px-3 py-1 text-sm font-medium rounded border ${statusColors[race.status]}`}>
{race.status.charAt(0).toUpperCase() + race.status.slice(1)}
</span>
</div>
</div>
{/* Companion Instructions for Scheduled Races */}
{race.status === 'scheduled' && (
<div className="mb-6">
<CompanionInstructions race={race} leagueName={league?.name} />
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Race Details */}
<Card className="lg:col-span-2">
<h2 className="text-xl font-semibold text-white mb-6">Race Details</h2>
<div className="space-y-6">
{/* Date & Time */}
<div>
<label className="text-sm text-gray-500 block mb-1">Scheduled Date & Time</label>
<p className="text-white text-lg font-medium">
{formatDateTime(race.scheduledAt)}
</p>
<div className="flex gap-4 mt-2 text-sm">
<span className="text-gray-400">{formatDate(race.scheduledAt)}</span>
<span className="text-gray-400">{formatTime(race.scheduledAt)}</span>
</div>
</div>
{/* Track */}
<div className="pt-4 border-t border-charcoal-outline">
<label className="text-sm text-gray-500 block mb-1">Track</label>
<p className="text-white">{race.track}</p>
</div>
{/* Car */}
<div>
<label className="text-sm text-gray-500 block mb-1">Car</label>
<p className="text-white">{race.car}</p>
</div>
{/* Session Type */}
<div>
<label className="text-sm text-gray-500 block mb-1">Session Type</label>
<p className="text-white capitalize">{race.sessionType}</p>
</div>
{/* League */}
<div className="pt-4 border-t border-charcoal-outline">
<label className="text-sm text-gray-500 block mb-1">League</label>
{league ? (
<button
onClick={() => router.push(`/leagues/${league.id}`)}
className="text-primary-blue hover:underline"
>
{league.name}
</button>
) : (
<p className="text-white">ID: {race.leagueId.slice(0, 8)}...</p>
)}
</div>
</div>
</Card>
{/* Actions */}
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Actions</h2>
<div className="space-y-3">
{race.status === 'completed' && (
<Button
variant="primary"
className="w-full"
onClick={() => router.push(`/races/${race.id}/results`)}
>
View Results
</Button>
)}
{race.status === 'scheduled' && (
<Button
variant="secondary"
className="w-full"
onClick={handleCancelRace}
disabled={cancelling}
>
{cancelling ? 'Cancelling...' : 'Cancel Race'}
</Button>
)}
<Button
variant="secondary"
className="w-full"
onClick={() => router.push('/races')}
>
Back to Races
</Button>
</div>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,247 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import ResultsTable from '@/components/alpha/ResultsTable';
import ImportResultsForm from '@/components/alpha/ImportResultsForm';
import { Race } from '@/domain/entities/Race';
import { League } from '@/domain/entities/League';
import { Result } from '@/domain/entities/Result';
import { Driver } from '@/domain/entities/Driver';
import {
getRaceRepository,
getLeagueRepository,
getResultRepository,
getStandingRepository,
getDriverRepository
} from '@/lib/di-container';
export default function RaceResultsPage() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const [race, setRace] = useState<Race | null>(null);
const [league, setLeague] = useState<League | null>(null);
const [results, setResults] = useState<Result[]>([]);
const [drivers, setDrivers] = useState<Driver[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [importSuccess, setImportSuccess] = useState(false);
const loadData = async () => {
try {
const raceRepo = getRaceRepository();
const leagueRepo = getLeagueRepository();
const resultRepo = getResultRepository();
const driverRepo = getDriverRepository();
const raceData = await raceRepo.findById(raceId);
if (!raceData) {
setError('Race not found');
setLoading(false);
return;
}
setRace(raceData);
// Load league data
const leagueData = await leagueRepo.findById(raceData.leagueId);
setLeague(leagueData);
// Load results
const resultsData = await resultRepo.findByRaceId(raceId);
setResults(resultsData);
// Load drivers
const driversData = await driverRepo.findAll();
setDrivers(driversData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load race data');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [raceId]);
const handleImportSuccess = async (importedResults: Result[]) => {
setImporting(true);
setError(null);
try {
const resultRepo = getResultRepository();
const standingRepo = getStandingRepository();
// Check if results already exist
const existingResults = await resultRepo.existsByRaceId(raceId);
if (existingResults) {
throw new Error('Results already exist for this race');
}
// Create all results
await resultRepo.createMany(importedResults);
// Recalculate standings for the league
if (league) {
await standingRepo.recalculate(league.id);
}
// Reload results
const resultsData = await resultRepo.findByRaceId(raceId);
setResults(resultsData);
setImportSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to import results');
} finally {
setImporting(false);
}
};
const handleImportError = (errorMessage: string) => {
setError(errorMessage);
};
const getPointsSystem = (): Record<number, number> => {
if (!league) return {};
const pointsSystems: Record<string, Record<number, number>> = {
'f1-2024': {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10,
6: 8, 7: 6, 8: 4, 9: 2, 10: 1
},
'indycar': {
1: 50, 2: 40, 3: 35, 4: 32, 5: 30,
6: 28, 7: 26, 8: 24, 9: 22, 10: 20,
11: 19, 12: 18, 13: 17, 14: 16, 15: 15
}
};
return league.settings.customPoints ||
pointsSystems[league.settings.pointsSystem] ||
pointsSystems['f1-2024'];
};
const getFastestLapTime = (): number | undefined => {
if (results.length === 0) return undefined;
return Math.min(...results.map(r => r.fastestLap));
};
if (loading) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading results...</div>
</div>
</div>
);
}
if (error && !race) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<Card className="text-center py-12">
<div className="text-warning-amber mb-4">
{error || 'Race not found'}
</div>
<Button
variant="secondary"
onClick={() => router.push('/races')}
>
Back to Races
</Button>
</Card>
</div>
</div>
);
}
const hasResults = results.length > 0;
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
{/* Breadcrumb */}
<div className="mb-6">
<button
onClick={() => router.push(`/races/${raceId}`)}
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Race Details
</button>
</div>
{/* Page Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Race Results</h1>
{race && (
<div>
<p className="text-gray-400">{race.track}</p>
{league && (
<p className="text-sm text-gray-500">{league.name}</p>
)}
</div>
)}
</div>
{/* Success Message */}
{importSuccess && (
<div className="mb-6 p-4 bg-performance-green/10 border border-performance-green/30 rounded text-performance-green">
<strong>Success!</strong> Results imported and standings updated.
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-warning-amber/10 border border-warning-amber/30 rounded text-warning-amber">
<strong>Error:</strong> {error}
</div>
)}
{/* Content */}
<Card>
{hasResults ? (
<>
<h2 className="text-xl font-semibold text-white mb-6">Results</h2>
<ResultsTable
results={results}
drivers={drivers}
pointsSystem={getPointsSystem()}
fastestLapTime={getFastestLapTime()}
/>
</>
) : (
<>
<h2 className="text-xl font-semibold text-white mb-6">Import Results</h2>
<p className="text-gray-400 text-sm mb-6">
No results imported. Upload CSV to test the standings system.
</p>
{importing ? (
<div className="text-center py-8 text-gray-400">
Importing results and updating standings...
</div>
) : (
<ImportResultsForm
raceId={raceId}
onSuccess={handleImportSuccess}
onError={handleImportError}
/>
)}
</>
)}
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,224 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import RaceCard from '@/components/alpha/RaceCard';
import ScheduleRaceForm from '@/components/alpha/ScheduleRaceForm';
import { Race, RaceStatus } from '@/domain/entities/Race';
import { League } from '@/domain/entities/League';
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
export default function RacesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [races, setRaces] = useState<Race[]>([]);
const [leagues, setLeagues] = useState<Map<string, League>>(new Map());
const [loading, setLoading] = useState(true);
const [showScheduleForm, setShowScheduleForm] = useState(false);
// Filters
const [statusFilter, setStatusFilter] = useState<RaceStatus | 'all'>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');
const [timeFilter, setTimeFilter] = useState<'all' | 'upcoming' | 'past'>('all');
const loadRaces = async () => {
try {
const raceRepo = getRaceRepository();
const leagueRepo = getLeagueRepository();
const [allRaces, allLeagues] = await Promise.all([
raceRepo.findAll(),
leagueRepo.findAll()
]);
setRaces(allRaces.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime()));
const leagueMap = new Map<string, League>();
allLeagues.forEach(league => leagueMap.set(league.id, league));
setLeagues(leagueMap);
} catch (err) {
console.error('Failed to load races:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadRaces();
}, []);
const filteredRaces = races.filter(race => {
// Status filter
if (statusFilter !== 'all' && race.status !== statusFilter) {
return false;
}
// League filter
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
return false;
}
// Time filter
if (timeFilter === 'upcoming' && !race.isUpcoming()) {
return false;
}
if (timeFilter === 'past' && !race.isPast()) {
return false;
}
return true;
});
if (loading) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading races...</div>
</div>
</div>
);
}
if (showScheduleForm) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<button
onClick={() => setShowScheduleForm(false)}
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Races
</button>
</div>
<Card>
<h1 className="text-2xl font-bold text-white mb-6">Schedule New Race</h1>
<ScheduleRaceForm
preSelectedLeagueId={searchParams.get('leagueId') || undefined}
onSuccess={(race) => {
router.push(`/races/${race.id}`);
}}
onCancel={() => setShowScheduleForm(false)}
/>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl font-bold text-white">Races</h1>
<Button
variant="primary"
onClick={() => setShowScheduleForm(true)}
>
Schedule Race
</Button>
</div>
<p className="text-gray-400">
Manage and view all scheduled races across your leagues
</p>
</div>
{/* Filters */}
<Card className="mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Time Filter */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Time
</label>
<select
value={timeFilter}
onChange={(e) => setTimeFilter(e.target.value as typeof timeFilter)}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="all">All Races</option>
<option value="upcoming">Upcoming</option>
<option value="past">Past</option>
</select>
</div>
{/* Status Filter */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Status
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="all">All Statuses</option>
<option value="scheduled">Scheduled</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
{/* League Filter */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
League
</label>
<select
value={leagueFilter}
onChange={(e) => setLeagueFilter(e.target.value)}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="all">All Leagues</option>
{Array.from(leagues.values()).map(league => (
<option key={league.id} value={league.id}>
{league.name}
</option>
))}
</select>
</div>
</div>
</Card>
{/* Race List */}
{filteredRaces.length === 0 ? (
<Card className="text-center py-12">
<div className="text-gray-400 mb-4">
{races.length === 0 ? (
<>
<p className="mb-2">No races scheduled</p>
<p className="text-sm text-gray-500">Try the full workflow in alpha mode</p>
</>
) : (
<>
<p className="mb-2">No races match your filters</p>
<p className="text-sm text-gray-500">Try adjusting your filter criteria</p>
</>
)}
</div>
</Card>
) : (
<div className="space-y-4">
{filteredRaces.map(race => (
<RaceCard
key={race.id}
race={race}
leagueName={leagues.get(race.leagueId)?.name}
onClick={() => router.push(`/races/${race.id}`)}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
/**
* Application Layer: Entity to DTO Mappers
*
* Transforms domain entities to plain objects for crossing architectural boundaries.
* These mappers handle the Server Component -> Client Component boundary in Next.js 15.
*/
import { Driver } from '@/domain/entities/Driver';
import { League } from '@/domain/entities/League';
import { Race } from '@/domain/entities/Race';
import { Result } from '@/domain/entities/Result';
import { Standing } from '@/domain/entities/Standing';
export type DriverDTO = {
id: string;
iracingId: string;
name: string;
country: string;
bio?: string;
joinedAt: string;
};
export type LeagueDTO = {
id: string;
name: string;
description: string;
ownerId: string;
settings: {
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
sessionDuration?: number;
qualifyingFormat?: 'single-lap' | 'open';
customPoints?: Record<number, number>;
};
createdAt: string;
};
export type RaceDTO = {
id: string;
leagueId: string;
scheduledAt: string;
track: string;
car: string;
sessionType: 'practice' | 'qualifying' | 'race';
status: 'scheduled' | 'completed' | 'cancelled';
};
export type ResultDTO = {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
};
export type StandingDTO = {
leagueId: string;
driverId: string;
points: number;
wins: number;
position: number;
racesCompleted: number;
};
export class EntityMappers {
static toDriverDTO(driver: Driver | null): DriverDTO | null {
if (!driver) return null;
return {
id: driver.id,
iracingId: driver.iracingId,
name: driver.name,
country: driver.country,
bio: driver.bio,
joinedAt: driver.joinedAt.toISOString(),
};
}
static toLeagueDTO(league: League | null): LeagueDTO | null {
if (!league) return null;
return {
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt.toISOString(),
};
}
static toLeagueDTOs(leagues: League[]): LeagueDTO[] {
return leagues.map(league => ({
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt.toISOString(),
}));
}
static toRaceDTO(race: Race | null): RaceDTO | null {
if (!race) return null;
return {
id: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
car: race.car,
sessionType: race.sessionType,
status: race.status,
};
}
static toRaceDTOs(races: Race[]): RaceDTO[] {
return races.map(race => ({
id: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
car: race.car,
sessionType: race.sessionType,
status: race.status,
}));
}
static toResultDTO(result: Result | null): ResultDTO | null {
if (!result) return null;
return {
id: result.id,
raceId: result.raceId,
driverId: result.driverId,
position: result.position,
fastestLap: result.fastestLap,
incidents: result.incidents,
startPosition: result.startPosition,
};
}
static toResultDTOs(results: Result[]): ResultDTO[] {
return results.map(result => ({
id: result.id,
raceId: result.raceId,
driverId: result.driverId,
position: result.position,
fastestLap: result.fastestLap,
incidents: result.incidents,
startPosition: result.startPosition,
}));
}
static toStandingDTO(standing: Standing | null): StandingDTO | null {
if (!standing) return null;
return {
leagueId: standing.leagueId,
driverId: standing.driverId,
points: standing.points,
wins: standing.wins,
position: standing.position,
racesCompleted: standing.racesCompleted,
};
}
static toStandingDTOs(standings: Standing[]): StandingDTO[] {
return standings.map(standing => ({
leagueId: standing.leagueId,
driverId: standing.driverId,
points: standing.points,
wins: standing.wins,
position: standing.position,
racesCompleted: standing.racesCompleted,
}));
}
}

View File

@@ -0,0 +1,50 @@
/**
* Application Port: IDriverRepository
*
* Repository interface for Driver entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import { Driver } from '../../domain/entities/Driver';
export interface IDriverRepository {
/**
* Find a driver by ID
*/
findById(id: string): Promise<Driver | null>;
/**
* Find a driver by iRacing ID
*/
findByIRacingId(iracingId: string): Promise<Driver | null>;
/**
* Find all drivers
*/
findAll(): Promise<Driver[]>;
/**
* Create a new driver
*/
create(driver: Driver): Promise<Driver>;
/**
* Update an existing driver
*/
update(driver: Driver): Promise<Driver>;
/**
* Delete a driver by ID
*/
delete(id: string): Promise<void>;
/**
* Check if a driver exists by ID
*/
exists(id: string): Promise<boolean>;
/**
* Check if an iRacing ID is already registered
*/
existsByIRacingId(iracingId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,50 @@
/**
* Application Port: ILeagueRepository
*
* Repository interface for League entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import { League } from '../../domain/entities/League';
export interface ILeagueRepository {
/**
* Find a league by ID
*/
findById(id: string): Promise<League | null>;
/**
* Find all leagues
*/
findAll(): Promise<League[]>;
/**
* Find leagues by owner ID
*/
findByOwnerId(ownerId: string): Promise<League[]>;
/**
* Create a new league
*/
create(league: League): Promise<League>;
/**
* Update an existing league
*/
update(league: League): Promise<League>;
/**
* Delete a league by ID
*/
delete(id: string): Promise<void>;
/**
* Check if a league exists by ID
*/
exists(id: string): Promise<boolean>;
/**
* Search leagues by name
*/
searchByName(query: string): Promise<League[]>;
}

View File

@@ -0,0 +1,65 @@
/**
* Application Port: IRaceRepository
*
* Repository interface for Race entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import { Race, RaceStatus } from '../../domain/entities/Race';
export interface IRaceRepository {
/**
* Find a race by ID
*/
findById(id: string): Promise<Race | null>;
/**
* Find all races
*/
findAll(): Promise<Race[]>;
/**
* Find races by league ID
*/
findByLeagueId(leagueId: string): Promise<Race[]>;
/**
* Find upcoming races for a league
*/
findUpcomingByLeagueId(leagueId: string): Promise<Race[]>;
/**
* Find completed races for a league
*/
findCompletedByLeagueId(leagueId: string): Promise<Race[]>;
/**
* Find races by status
*/
findByStatus(status: RaceStatus): Promise<Race[]>;
/**
* Find races scheduled within a date range
*/
findByDateRange(startDate: Date, endDate: Date): Promise<Race[]>;
/**
* Create a new race
*/
create(race: Race): Promise<Race>;
/**
* Update an existing race
*/
update(race: Race): Promise<Race>;
/**
* Delete a race by ID
*/
delete(id: string): Promise<void>;
/**
* Check if a race exists by ID
*/
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,70 @@
/**
* Application Port: IResultRepository
*
* Repository interface for Result entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import { Result } from '../../domain/entities/Result';
export interface IResultRepository {
/**
* Find a result by ID
*/
findById(id: string): Promise<Result | null>;
/**
* Find all results
*/
findAll(): Promise<Result[]>;
/**
* Find results by race ID
*/
findByRaceId(raceId: string): Promise<Result[]>;
/**
* Find results by driver ID
*/
findByDriverId(driverId: string): Promise<Result[]>;
/**
* Find results by driver ID for a specific league
*/
findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Result[]>;
/**
* Create a new result
*/
create(result: Result): Promise<Result>;
/**
* Create multiple results
*/
createMany(results: Result[]): Promise<Result[]>;
/**
* Update an existing result
*/
update(result: Result): Promise<Result>;
/**
* Delete a result by ID
*/
delete(id: string): Promise<void>;
/**
* Delete all results for a race
*/
deleteByRaceId(raceId: string): Promise<void>;
/**
* Check if a result exists by ID
*/
exists(id: string): Promise<boolean>;
/**
* Check if results exist for a race
*/
existsByRaceId(raceId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,55 @@
/**
* Application Port: IStandingRepository
*
* Repository interface for Standing entity operations.
* Includes methods for calculating and retrieving standings.
*/
import { Standing } from '../../domain/entities/Standing';
export interface IStandingRepository {
/**
* Find standings by league ID (sorted by position)
*/
findByLeagueId(leagueId: string): Promise<Standing[]>;
/**
* Find standing for a specific driver in a league
*/
findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Standing | null>;
/**
* Find all standings
*/
findAll(): Promise<Standing[]>;
/**
* Create or update a standing
*/
save(standing: Standing): Promise<Standing>;
/**
* Create or update multiple standings
*/
saveMany(standings: Standing[]): Promise<Standing[]>;
/**
* Delete a standing
*/
delete(leagueId: string, driverId: string): Promise<void>;
/**
* Delete all standings for a league
*/
deleteByLeagueId(leagueId: string): Promise<void>;
/**
* Check if a standing exists
*/
exists(leagueId: string, driverId: string): Promise<boolean>;
/**
* Recalculate standings for a league based on race results
*/
recalculate(leagueId: string): Promise<Standing[]>;
}

View File

@@ -0,0 +1,50 @@
'use client';
import { useState, useEffect } from 'react';
export default function AlphaBanner() {
const [isDismissed, setIsDismissed] = useState(false);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
const dismissed = sessionStorage.getItem('alpha-banner-dismissed');
if (dismissed === 'true') {
setIsDismissed(true);
}
}, []);
const handleDismiss = () => {
sessionStorage.setItem('alpha-banner-dismissed', 'true');
setIsDismissed(true);
};
if (!isMounted) return null;
if (isDismissed) return null;
return (
<div className="sticky top-0 z-50 bg-warning-amber/10 border-b border-warning-amber/20 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-warning-amber flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-sm text-white">
Alpha Version Data resets on page reload. No persistent storage.
</p>
</div>
<button
onClick={handleDismiss}
className="text-gray-400 hover:text-white transition-colors p-1"
aria-label="Dismiss banner"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
export default function AlphaFooter() {
return (
<footer className="mt-auto border-t border-charcoal-outline bg-deep-graphite">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="px-2 py-1 bg-warning-amber/10 text-warning-amber rounded border border-warning-amber/20 font-medium">
Alpha v0.1
</span>
<span>In-memory prototype</span>
</div>
<div className="flex items-center gap-6 text-sm">
<a
href="https://discord.gg/gridpilot"
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-primary-blue transition-colors"
>
Give Feedback
</a>
<a
href="/docs/roadmap"
className="text-gray-400 hover:text-primary-blue transition-colors"
>
Roadmap
</a>
<a
href="/"
className="text-gray-400 hover:text-primary-blue transition-colors"
>
Back to Landing
</a>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const navLinks = [
{ href: '/', label: 'Dashboard' },
{ href: '/profile', label: 'Profile' },
{ href: '/leagues', label: 'Leagues' },
{ href: '/races', label: 'Races' },
] as const;
export function AlphaNav() {
const pathname = usePathname();
return (
<nav className="sticky top-0 z-40 bg-deep-graphite/95 backdrop-blur-md border-b border-white/5">
<div className="max-w-7xl mx-auto px-6">
<div className="flex items-center justify-between h-14">
<div className="flex items-baseline space-x-3">
<Link href="/" className="text-xl font-semibold text-white hover:text-primary-blue transition-colors">
GridPilot
</Link>
<span className="text-xs text-gray-500 font-light">ALPHA</span>
</div>
<div className="hidden md:flex items-center space-x-1">
{navLinks.map((link) => {
const isActive = pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
className={`
relative px-4 py-2 text-sm font-medium transition-all duration-200
${isActive
? 'text-primary-blue'
: 'text-gray-400 hover:text-white'
}
`}
>
{link.label}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-blue rounded-full" />
)}
</Link>
);
})}
</div>
<div className="md:hidden w-8" />
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import Card from '../ui/Card';
import Button from '../ui/Button';
import { Race } from '../../domain/entities/Race';
interface CompanionInstructionsProps {
race: Race;
leagueName?: string;
}
export default function CompanionInstructions({ race, leagueName }: CompanionInstructionsProps) {
const [copied, setCopied] = useState(false);
const formatDateTime = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
};
const raceDetails = `GridPilot Race: ${leagueName || 'League'}
Track: ${race.track}
Car: ${race.car}
Date/Time: ${formatDateTime(race.scheduledAt)}
Session Type: ${race.sessionType.charAt(0).toUpperCase() + race.sessionType.slice(1)}`;
const handleCopyDetails = async () => {
try {
await navigator.clipboard.writeText(raceDetails);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<Card className="border border-primary-blue/20 bg-iron-gray">
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-primary-blue/10 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-1">Alpha Manual Workflow</h3>
<p className="text-sm text-gray-400">
Companion automation coming in production. For alpha, races are created manually.
</p>
</div>
</div>
<div className="space-y-3 mb-4">
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary-blue/20 text-primary-blue text-xs font-semibold flex-shrink-0">
1
</span>
<p className="text-sm text-gray-300 pt-0.5">
Schedule race in GridPilot (completed)
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
2
</span>
<p className="text-sm text-gray-300 pt-0.5">
Copy race details using button below
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
3
</span>
<p className="text-sm text-gray-300 pt-0.5">
Create hosted session manually in iRacing website
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
4
</span>
<p className="text-sm text-gray-300 pt-0.5">
Return to GridPilot after race completes
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
5
</span>
<p className="text-sm text-gray-300 pt-0.5">
Import results via CSV upload
</p>
</div>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<div className="bg-deep-graphite rounded-lg p-3 mb-3">
<pre className="text-xs text-gray-300 whitespace-pre-wrap font-mono">
{raceDetails}
</pre>
</div>
<Button
variant="primary"
onClick={handleCopyDetails}
className="w-full"
>
<div className="flex items-center justify-center gap-2">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{copied ? 'Copied!' : 'Copy Race Details'}
</div>
</Button>
</div>
</Card>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
interface CompanionStatusProps {
className?: string;
}
export default function CompanionStatus({ className = '' }: CompanionStatusProps) {
// Alpha: always disconnected
const isConnected = false;
const statusMessage = "Companion app available in production";
return (
<div className={`flex items-center gap-3 ${className}`}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-performance-green' : 'bg-gray-500'}`} />
<span className="text-sm text-gray-400">
Companion App: <span className={isConnected ? 'text-performance-green' : 'text-gray-400'}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</span>
</div>
<span className="text-xs text-gray-500">
{statusMessage}
</span>
</div>
);
}

View File

@@ -0,0 +1,187 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Input from '../ui/Input';
import Button from '../ui/Button';
import DataWarning from './DataWarning';
import { Driver } from '../../domain/entities/Driver';
import { getDriverRepository } from '../../lib/di-container';
interface FormErrors {
name?: string;
iracingId?: string;
country?: string;
bio?: string;
submit?: string;
}
export default function CreateDriverForm() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
const [formData, setFormData] = useState({
name: '',
iracingId: '',
country: '',
bio: ''
});
const validateForm = async (): Promise<boolean> => {
const newErrors: FormErrors = {};
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
}
if (!formData.iracingId.trim()) {
newErrors.iracingId = 'iRacing ID is required';
} else {
const driverRepo = getDriverRepository();
const exists = await driverRepo.existsByIRacingId(formData.iracingId);
if (exists) {
newErrors.iracingId = 'This iRacing ID is already registered';
}
}
if (!formData.country.trim()) {
newErrors.country = 'Country is required';
} else if (!/^[A-Z]{2,3}$/i.test(formData.country)) {
newErrors.country = 'Invalid country code (use 2-3 letter ISO code)';
}
if (formData.bio && formData.bio.length > 500) {
newErrors.bio = 'Bio must be 500 characters or less';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
const isValid = await validateForm();
if (!isValid) return;
setLoading(true);
try {
const driverRepo = getDriverRepository();
const driver = Driver.create({
id: crypto.randomUUID(),
iracingId: formData.iracingId.trim(),
name: formData.name.trim(),
country: formData.country.trim().toUpperCase(),
bio: formData.bio.trim() || undefined,
});
await driverRepo.create(driver);
router.push('/profile');
router.refresh();
} catch (error) {
setErrors({
submit: error instanceof Error ? error.message : 'Failed to create profile'
});
setLoading(false);
}
};
return (
<>
<DataWarning />
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
Driver Name *
</label>
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
errorMessage={errors.name}
placeholder="Max Verstappen"
disabled={loading}
/>
</div>
<div>
<label htmlFor="iracingId" className="block text-sm font-medium text-gray-300 mb-2">
iRacing ID *
</label>
<Input
id="iracingId"
type="text"
value={formData.iracingId}
onChange={(e) => setFormData({ ...formData, iracingId: e.target.value })}
error={!!errors.iracingId}
errorMessage={errors.iracingId}
placeholder="123456"
disabled={loading}
/>
</div>
<div>
<label htmlFor="country" className="block text-sm font-medium text-gray-300 mb-2">
Country Code *
</label>
<Input
id="country"
type="text"
value={formData.country}
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
error={!!errors.country}
errorMessage={errors.country}
placeholder="NL"
maxLength={3}
disabled={loading}
/>
<p className="mt-1 text-xs text-gray-500">Use ISO 3166-1 alpha-2 or alpha-3 code</p>
</div>
<div>
<label htmlFor="bio" className="block text-sm font-medium text-gray-300 mb-2">
Bio (Optional)
</label>
<textarea
id="bio"
value={formData.bio}
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
placeholder="Tell us about yourself..."
maxLength={500}
rows={4}
disabled={loading}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none"
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.bio.length}/500
</p>
{errors.bio && (
<p className="mt-2 text-sm text-warning-amber">{errors.bio}</p>
)}
</div>
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-4 border border-warning-amber/20">
<p className="text-sm text-warning-amber">{errors.submit}</p>
</div>
)}
<Button
type="submit"
variant="primary"
disabled={loading}
className="w-full"
>
{loading ? 'Creating Profile...' : 'Create Profile'}
</Button>
</form>
</>
);
}

View File

@@ -0,0 +1,195 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Input from '../ui/Input';
import Button from '../ui/Button';
import DataWarning from './DataWarning';
import { League } from '../../domain/entities/League';
import { getLeagueRepository, getDriverRepository } from '../../lib/di-container';
interface FormErrors {
name?: string;
description?: string;
pointsSystem?: string;
sessionDuration?: string;
submit?: string;
}
export default function CreateLeagueForm() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
const [formData, setFormData] = useState({
name: '',
description: '',
pointsSystem: 'f1-2024' as 'f1-2024' | 'indycar',
sessionDuration: 60
});
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
} else if (formData.name.length > 100) {
newErrors.name = 'Name must be 100 characters or less';
}
if (!formData.description.trim()) {
newErrors.description = 'Description is required';
} else if (formData.description.length > 500) {
newErrors.description = 'Description must be 500 characters or less';
}
if (formData.sessionDuration < 1 || formData.sessionDuration > 240) {
newErrors.sessionDuration = 'Session duration must be between 1 and 240 minutes';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
if (!validateForm()) return;
setLoading(true);
try {
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const currentDriver = drivers[0];
if (!currentDriver) {
setErrors({ submit: 'No driver profile found. Please create a profile first.' });
setLoading(false);
return;
}
const leagueRepo = getLeagueRepository();
const league = League.create({
id: crypto.randomUUID(),
name: formData.name.trim(),
description: formData.description.trim(),
ownerId: currentDriver.id,
settings: {
pointsSystem: formData.pointsSystem,
sessionDuration: formData.sessionDuration,
qualifyingFormat: 'open',
},
});
await leagueRepo.create(league);
router.push(`/leagues/${league.id}`);
router.refresh();
} catch (error) {
setErrors({
submit: error instanceof Error ? error.message : 'Failed to create league'
});
setLoading(false);
}
};
return (
<>
<DataWarning />
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
League Name *
</label>
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
errorMessage={errors.name}
placeholder="European GT Championship"
maxLength={100}
disabled={loading}
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.name.length}/100
</p>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-2">
Description *
</label>
<textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Weekly GT3 racing with professional drivers"
maxLength={500}
rows={4}
disabled={loading}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none"
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.description.length}/500
</p>
{errors.description && (
<p className="mt-2 text-sm text-warning-amber">{errors.description}</p>
)}
</div>
<div>
<label htmlFor="pointsSystem" className="block text-sm font-medium text-gray-300 mb-2">
Points System *
</label>
<select
id="pointsSystem"
value={formData.pointsSystem}
onChange={(e) => setFormData({ ...formData, pointsSystem: e.target.value as 'f1-2024' | 'indycar' })}
disabled={loading}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6"
>
<option value="f1-2024">F1 2024</option>
<option value="indycar">IndyCar</option>
</select>
</div>
<div>
<label htmlFor="sessionDuration" className="block text-sm font-medium text-gray-300 mb-2">
Session Duration (minutes) *
</label>
<Input
id="sessionDuration"
type="number"
value={formData.sessionDuration}
onChange={(e) => setFormData({ ...formData, sessionDuration: parseInt(e.target.value) || 60 })}
error={!!errors.sessionDuration}
errorMessage={errors.sessionDuration}
min={1}
max={240}
disabled={loading}
/>
</div>
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-4 border border-warning-amber/20">
<p className="text-sm text-warning-amber">{errors.submit}</p>
</div>
)}
<Button
type="submit"
variant="primary"
disabled={loading}
className="w-full"
>
{loading ? 'Creating League...' : 'Create League'}
</Button>
</form>
</>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useState, useEffect } from 'react';
export default function DataWarning() {
const [isDismissed, setIsDismissed] = useState(false);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
const dismissed = sessionStorage.getItem('data-warning-dismissed');
if (dismissed === 'true') {
setIsDismissed(true);
}
}, []);
const handleDismiss = () => {
sessionStorage.setItem('data-warning-dismissed', 'true');
setIsDismissed(true);
};
if (!isMounted) return null;
if (isDismissed) return null;
return (
<div className="mb-6 bg-iron-gray border border-charcoal-outline rounded-lg p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-primary-blue/10 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p className="text-sm text-gray-300">
Your data will be lost when you refresh the page. Alpha uses in-memory storage only.
</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-gray-400 hover:text-white transition-colors p-1 flex-shrink-0"
aria-label="Dismiss warning"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { DriverDTO } from '@/application/mappers/EntityMappers';
import Card from '../ui/Card';
import Button from '../ui/Button';
interface DriverProfileProps {
driver: DriverDTO;
}
export default function DriverProfile({ driver }: DriverProfileProps) {
const formattedDate = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(new Date(driver.joinedAt));
return (
<Card className="max-w-2xl mx-auto">
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold text-white mb-2">{driver.name}</h2>
<p className="text-gray-400 text-sm">iRacing ID: {driver.iracingId}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-3xl" aria-label={`Country: ${driver.country}`}>
{getCountryFlag(driver.country)}
</span>
</div>
</div>
{driver.bio && (
<div className="border-t border-charcoal-outline pt-4">
<h3 className="text-sm font-semibold text-gray-400 mb-2">Bio</h3>
<p className="text-gray-300 leading-relaxed">{driver.bio}</p>
</div>
)}
<div className="border-t border-charcoal-outline pt-4">
<div className="flex items-center gap-2 text-sm text-gray-400">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>Member since {formattedDate}</span>
</div>
</div>
<div className="pt-4">
<Button
variant="secondary"
className="w-full"
disabled
>
Edit Profile
</Button>
<p className="text-xs text-gray-500 text-center mt-2">
Profile editing coming soon
</p>
</div>
</div>
</Card>
);
}
function getCountryFlag(countryCode: string): string {
const code = countryCode.toUpperCase();
if (code.length === 2) {
const codePoints = [...code].map(char =>
127397 + char.charCodeAt(0)
);
return String.fromCodePoint(...codePoints);
}
return '🏁';
}

View File

@@ -0,0 +1,23 @@
'use client';
interface FeatureLimitationTooltipProps {
message: string;
children: React.ReactNode;
}
export default function FeatureLimitationTooltip({ message, children }: FeatureLimitationTooltipProps) {
return (
<div className="group relative inline-block">
{children}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-sm text-gray-300 whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 pointer-events-none z-50">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary-blue flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{message}</span>
</div>
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-iron-gray" />
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import { useState } from 'react';
import Button from '../ui/Button';
import DataWarning from './DataWarning';
import { Result } from '../../domain/entities/Result';
import { v4 as uuidv4 } from 'uuid';
interface ImportResultsFormProps {
raceId: string;
onSuccess: (results: Result[]) => void;
onError: (error: string) => void;
}
interface CSVRow {
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
export default function ImportResultsForm({ raceId, onSuccess, onError }: ImportResultsFormProps) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const parseCSV = (content: string): CSVRow[] => {
const lines = content.trim().split('\n');
if (lines.length < 2) {
throw new Error('CSV file is empty or invalid');
}
// Parse header
const header = lines[0].toLowerCase().split(',').map(h => h.trim());
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
for (const field of requiredFields) {
if (!header.includes(field)) {
throw new Error(`Missing required field: ${field}`);
}
}
// Parse rows
const rows: CSVRow[] = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim());
if (values.length !== header.length) {
throw new Error(`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`);
}
const row: any = {};
header.forEach((field, index) => {
row[field] = values[index];
});
// Validate and convert types
const driverId = row.driverid;
const position = parseInt(row.position, 10);
const fastestLap = parseFloat(row.fastestlap);
const incidents = parseInt(row.incidents, 10);
const startPosition = parseInt(row.startposition, 10);
if (!driverId || driverId.length === 0) {
throw new Error(`Row ${i}: driverId is required`);
}
if (isNaN(position) || position < 1) {
throw new Error(`Row ${i}: position must be a positive integer`);
}
if (isNaN(fastestLap) || fastestLap < 0) {
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
}
if (isNaN(incidents) || incidents < 0) {
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
}
if (isNaN(startPosition) || startPosition < 1) {
throw new Error(`Row ${i}: startPosition must be a positive integer`);
}
rows.push({ driverId, position, fastestLap, incidents, startPosition });
}
// Validate no duplicate positions
const positions = rows.map(r => r.position);
const uniquePositions = new Set(positions);
if (positions.length !== uniquePositions.size) {
throw new Error('Duplicate positions found in CSV');
}
// Validate no duplicate drivers
const driverIds = rows.map(r => r.driverId);
const uniqueDrivers = new Set(driverIds);
if (driverIds.length !== uniqueDrivers.size) {
throw new Error('Duplicate driver IDs found in CSV');
}
return rows;
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
setError(null);
try {
// Read file
const content = await file.text();
// Parse CSV
const rows = parseCSV(content);
// Create Result entities
const results = rows.map(row =>
Result.create({
id: uuidv4(),
raceId,
driverId: row.driverId,
position: row.position,
fastestLap: row.fastestLap,
incidents: row.incidents,
startPosition: row.startPosition,
})
);
onSuccess(results);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to parse CSV file';
setError(errorMessage);
onError(errorMessage);
} finally {
setUploading(false);
// Reset file input
event.target.value = '';
}
};
return (
<>
<DataWarning />
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Upload Results CSV
</label>
<p className="text-xs text-gray-500 mb-3">
CSV format: driverId, position, fastestLap, incidents, startPosition
</p>
<input
type="file"
accept=".csv"
onChange={handleFileChange}
disabled={uploading}
className="block w-full text-sm text-gray-400
file:mr-4 file:py-2 file:px-4
file:rounded file:border-0
file:text-sm file:font-semibold
file:bg-primary-blue file:text-white
file:cursor-pointer file:transition-colors
hover:file:bg-primary-blue/80
disabled:file:opacity-50 disabled:file:cursor-not-allowed"
/>
</div>
{error && (
<div className="p-4 bg-warning-amber/10 border border-warning-amber/30 rounded text-warning-amber text-sm">
<strong>Error:</strong> {error}
</div>
)}
{uploading && (
<div className="text-center text-gray-400 text-sm">
Parsing CSV and importing results...
</div>
)}
<div className="p-4 bg-iron-gray/20 rounded text-xs text-gray-500">
<p className="font-semibold mb-2">CSV Example:</p>
<pre className="text-gray-400">
{`driverId,position,fastestLap,incidents,startPosition
550e8400-e29b-41d4-a716-446655440001,1,92.456,0,3
550e8400-e29b-41d4-a716-446655440002,2,92.789,1,1
550e8400-e29b-41d4-a716-446655440003,3,93.012,2,2`}
</pre>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { League } from '../../domain/entities/League';
import Card from '../ui/Card';
interface LeagueCardProps {
league: League;
onClick?: () => void;
}
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
return (
<div
className="cursor-pointer hover:scale-[1.03] transition-transform duration-150"
onClick={onClick}
>
<Card>
<div className="space-y-3">
<div className="flex items-start justify-between">
<h3 className="text-xl font-semibold text-white">{league.name}</h3>
<span className="text-xs text-gray-500">
{new Date(league.createdAt).toLocaleDateString()}
</span>
</div>
<p className="text-gray-400 text-sm line-clamp-2">
{league.description}
</p>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<div className="text-xs text-gray-500">
Owner ID: {league.ownerId.slice(0, 8)}...
</div>
<div className="text-xs text-primary-blue font-medium">
{league.settings.pointsSystem.toUpperCase()}
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { Race } from '../../domain/entities/Race';
interface RaceCardProps {
race: Race;
leagueName?: string;
onClick?: () => void;
}
export default function RaceCard({ race, leagueName, onClick }: RaceCardProps) {
const statusColors = {
scheduled: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30',
completed: 'bg-green-500/20 text-green-400 border-green-500/30',
cancelled: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const formatTime = (date: Date) => {
return new Date(date).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
};
const getRelativeTime = (date: Date) => {
const now = new Date();
const targetDate = new Date(date);
const diffMs = targetDate.getTime() - now.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 0) return null;
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Tomorrow';
if (diffDays < 7) return `in ${diffDays} days`;
return null;
};
const relativeTime = race.status === 'scheduled' ? getRelativeTime(race.scheduledAt) : null;
return (
<div
onClick={onClick}
className={`
p-6 rounded-lg bg-iron-gray border border-charcoal-outline
transition-all duration-200
${onClick ? 'cursor-pointer hover:scale-[1.03] hover:border-primary-blue' : ''}
`}
>
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-white">{race.track}</h3>
<span className={`px-2 py-1 text-xs font-medium rounded border ${statusColors[race.status]}`}>
{race.status.charAt(0).toUpperCase() + race.status.slice(1)}
</span>
</div>
<p className="text-gray-400 text-sm">{race.car}</p>
{leagueName && (
<p className="text-gray-500 text-xs mt-1">{leagueName}</p>
)}
</div>
<div className="text-right">
<p className="text-white font-medium text-sm">{formatDate(race.scheduledAt)}</p>
<p className="text-gray-400 text-xs">{formatTime(race.scheduledAt)}</p>
{relativeTime && (
<p className="text-primary-blue text-xs mt-1">{relativeTime}</p>
)}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500 uppercase tracking-wide">
{race.sessionType}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { Result } from '../../domain/entities/Result';
import { Driver } from '../../domain/entities/Driver';
interface ResultsTableProps {
results: Result[];
drivers: Driver[];
pointsSystem: Record<number, number>;
fastestLapTime?: number;
}
export default function ResultsTable({ results, drivers, pointsSystem, fastestLapTime }: ResultsTableProps) {
const getDriverName = (driverId: string): string => {
const driver = drivers.find(d => d.id === driverId);
return driver?.name || 'Unknown Driver';
};
const formatLapTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const secs = (seconds % 60).toFixed(3);
return `${minutes}:${secs.padStart(6, '0')}`;
};
const getPoints = (position: number): number => {
return pointsSystem[position] || 0;
};
const getPositionChangeColor = (change: number): string => {
if (change > 0) return 'text-performance-green';
if (change < 0) return 'text-warning-amber';
return 'text-gray-500';
};
const getPositionChangeText = (change: number): string => {
if (change > 0) return `+${change}`;
if (change < 0) return `${change}`;
return '0';
};
if (results.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
No results available
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Pos</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Driver</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Fastest Lap</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Incidents</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>
</tr>
</thead>
<tbody>
{results.map((result) => {
const positionChange = result.getPositionChange();
const isFastestLap = fastestLapTime && result.fastestLap === fastestLapTime;
return (
<tr
key={result.id}
className="border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors"
>
<td className="py-3 px-4">
<span className="text-white font-semibold">{result.position}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{getDriverName(result.driverId)}</span>
</td>
<td className="py-3 px-4">
<span className={isFastestLap ? 'text-performance-green font-medium' : 'text-white'}>
{formatLapTime(result.fastestLap)}
</span>
</td>
<td className="py-3 px-4">
<span className={result.incidents > 0 ? 'text-warning-amber' : 'text-white'}>
{result.incidents}×
</span>
</td>
<td className="py-3 px-4">
<span className="text-white font-medium">{getPoints(result.position)}</span>
</td>
<td className="py-3 px-4">
<span className={`font-medium ${getPositionChangeColor(positionChange)}`}>
{getPositionChangeText(positionChange)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,313 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Button from '../ui/Button';
import Input from '../ui/Input';
import DataWarning from './DataWarning';
import { Race } from '../../domain/entities/Race';
import { League } from '../../domain/entities/League';
import { SessionType } from '../../domain/entities/Race';
import { getRaceRepository, getLeagueRepository } from '../../lib/di-container';
import { InMemoryRaceRepository } from '../../infrastructure/repositories/InMemoryRaceRepository';
interface ScheduleRaceFormProps {
preSelectedLeagueId?: string;
onSuccess?: (race: Race) => void;
onCancel?: () => void;
}
export default function ScheduleRaceForm({
preSelectedLeagueId,
onSuccess,
onCancel
}: ScheduleRaceFormProps) {
const router = useRouter();
const [leagues, setLeagues] = useState<League[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
leagueId: preSelectedLeagueId || '',
track: '',
car: '',
sessionType: 'race' as SessionType,
scheduledDate: '',
scheduledTime: '',
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
useEffect(() => {
const loadLeagues = async () => {
const leagueRepo = getLeagueRepository();
const allLeagues = await leagueRepo.findAll();
setLeagues(allLeagues);
};
loadLeagues();
}, []);
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!formData.leagueId) {
errors.leagueId = 'League is required';
}
if (!formData.track.trim()) {
errors.track = 'Track is required';
}
if (!formData.car.trim()) {
errors.car = 'Car is required';
}
if (!formData.scheduledDate) {
errors.scheduledDate = 'Date is required';
}
if (!formData.scheduledTime) {
errors.scheduledTime = 'Time is required';
}
// Validate future date
if (formData.scheduledDate && formData.scheduledTime) {
const scheduledDateTime = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
const now = new Date();
if (scheduledDateTime <= now) {
errors.scheduledDate = 'Date must be in the future';
}
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setLoading(true);
setError(null);
try {
const raceRepo = getRaceRepository();
const scheduledAt = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
const race = Race.create({
id: InMemoryRaceRepository.generateId(),
leagueId: formData.leagueId,
track: formData.track.trim(),
car: formData.car.trim(),
sessionType: formData.sessionType,
scheduledAt,
status: 'scheduled',
});
const createdRace = await raceRepo.create(race);
if (onSuccess) {
onSuccess(createdRace);
} else {
router.push(`/races/${createdRace.id}`);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create race');
} finally {
setLoading(false);
}
};
const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear validation error for this field
if (validationErrors[field]) {
setValidationErrors(prev => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
return (
<>
<DataWarning />
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400">
{error}
</div>
)}
{/* Companion App Notice */}
<div className="p-4 rounded-lg bg-iron-gray border border-charcoal-outline">
<div className="flex items-start gap-3">
<div className="flex items-center gap-2 flex-1">
<input
type="checkbox"
disabled
className="w-4 h-4 rounded border-charcoal-outline bg-deep-graphite text-primary-blue opacity-50 cursor-not-allowed"
/>
<label className="text-sm text-gray-400">
Use Companion App
</label>
<button
type="button"
className="text-gray-500 hover:text-gray-400 transition-colors"
title="Companion automation available in production. For alpha, races are created manually."
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
</div>
<p className="text-xs text-gray-500 mt-2 ml-6">
Companion automation available in production. For alpha, races are created manually.
</p>
</div>
{/* League Selection */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
League *
</label>
<select
value={formData.leagueId}
onChange={(e) => handleChange('leagueId', e.target.value)}
disabled={!!preSelectedLeagueId}
className={`
w-full px-4 py-2 bg-deep-graphite border rounded-lg text-white
focus:outline-none focus:ring-2 focus:ring-primary-blue
disabled:opacity-50 disabled:cursor-not-allowed
${validationErrors.leagueId ? 'border-red-500' : 'border-charcoal-outline'}
`}
>
<option value="">Select a league</option>
{leagues.map(league => (
<option key={league.id} value={league.id}>
{league.name}
</option>
))}
</select>
{validationErrors.leagueId && (
<p className="mt-1 text-sm text-red-400">{validationErrors.leagueId}</p>
)}
</div>
{/* Track */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Track *
</label>
<Input
type="text"
value={formData.track}
onChange={(e) => handleChange('track', e.target.value)}
placeholder="e.g., Spa-Francorchamps"
className={validationErrors.track ? 'border-red-500' : ''}
/>
{validationErrors.track && (
<p className="mt-1 text-sm text-red-400">{validationErrors.track}</p>
)}
<p className="mt-1 text-xs text-gray-500">Enter the iRacing track name</p>
</div>
{/* Car */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Car *
</label>
<Input
type="text"
value={formData.car}
onChange={(e) => handleChange('car', e.target.value)}
placeholder="e.g., Porsche 911 GT3 R"
className={validationErrors.car ? 'border-red-500' : ''}
/>
{validationErrors.car && (
<p className="mt-1 text-sm text-red-400">{validationErrors.car}</p>
)}
<p className="mt-1 text-xs text-gray-500">Enter the iRacing car name</p>
</div>
{/* Session Type */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Session Type *
</label>
<select
value={formData.sessionType}
onChange={(e) => handleChange('sessionType', e.target.value)}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="practice">Practice</option>
<option value="qualifying">Qualifying</option>
<option value="race">Race</option>
</select>
</div>
{/* Date and Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Date *
</label>
<Input
type="date"
value={formData.scheduledDate}
onChange={(e) => handleChange('scheduledDate', e.target.value)}
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
/>
{validationErrors.scheduledDate && (
<p className="mt-1 text-sm text-red-400">{validationErrors.scheduledDate}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Time *
</label>
<Input
type="time"
value={formData.scheduledTime}
onChange={(e) => handleChange('scheduledTime', e.target.value)}
className={validationErrors.scheduledTime ? 'border-red-500' : ''}
/>
{validationErrors.scheduledTime && (
<p className="mt-1 text-sm text-red-400">{validationErrors.scheduledTime}</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button
type="submit"
variant="primary"
disabled={loading}
className="flex-1"
>
{loading ? 'Creating...' : 'Schedule Race'}
</Button>
{onCancel && (
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={loading}
>
Cancel
</Button>
)}
</div>
</form>
</>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { Standing } from '../../domain/entities/Standing';
import { Driver } from '../../domain/entities/Driver';
interface StandingsTableProps {
standings: Standing[];
drivers: Driver[];
}
export default function StandingsTable({ standings, drivers }: StandingsTableProps) {
const getDriverName = (driverId: string): string => {
const driver = drivers.find(d => d.id === driverId);
return driver?.name || 'Unknown Driver';
};
if (standings.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
No standings available
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Pos</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Driver</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">Wins</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Races</th>
</tr>
</thead>
<tbody>
{standings.map((standing) => {
const isLeader = standing.position === 1;
return (
<tr
key={`${standing.leagueId}-${standing.driverId}`}
className="border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors"
>
<td className="py-3 px-4">
<span className={`font-semibold ${isLeader ? 'text-yellow-500' : 'text-white'}`}>
{standing.position}
</span>
</td>
<td className="py-3 px-4">
<span className={isLeader ? 'text-white font-semibold' : 'text-white'}>
{getDriverName(standing.driverId)}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white font-medium">{standing.points}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{standing.wins}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{standing.racesCompleted}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { ReactNode } from 'react';
import { useState, useEffect, ReactNode } from 'react';
/**
* ModeGuard - Conditional rendering component based on application mode
@@ -10,12 +10,12 @@ import { ReactNode } from 'react';
* <PreLaunchContent />
* </ModeGuard>
*
* <ModeGuard mode="post-launch">
* <ModeGuard mode="alpha">
* <FullPlatformContent />
* </ModeGuard>
*/
export type GuardMode = 'pre-launch' | 'post-launch';
export type GuardMode = 'pre-launch' | 'alpha';
interface ModeGuardProps {
mode: GuardMode;
@@ -29,15 +29,25 @@ interface ModeGuardProps {
* This component is for conditional UI rendering within accessible pages
*/
export function ModeGuard({ mode, children, fallback = null }: ModeGuardProps) {
const currentMode = getClientMode();
const [isMounted, setIsMounted] = useState(false);
const [currentMode, setCurrentMode] = useState<GuardMode>('pre-launch');
if (currentMode === mode) {
return <>{children}</>;
useEffect(() => {
setIsMounted(true);
setCurrentMode(getClientMode());
}, []);
if (!isMounted) {
return <>{fallback}</>;
}
if (currentMode !== mode) {
return <>{fallback}</>;
}
return <>{children}</>;
}
/**
* Get mode on client side from injected environment variable
* Falls back to 'pre-launch' if not available
@@ -49,8 +59,8 @@ function getClientMode(): GuardMode {
const mode = process.env.NEXT_PUBLIC_GRIDPILOT_MODE;
if (mode === 'post-launch') {
return 'post-launch';
if (mode === 'alpha') {
return 'alpha';
}
return 'pre-launch';
@@ -71,8 +81,8 @@ export function useIsPreLaunch(): boolean {
}
/**
* Hook to check if in post-launch mode
* Hook to check if in alpha mode
*/
export function useIsPostLaunch(): boolean {
return getClientMode() === 'post-launch';
export function useIsAlpha(): boolean {
return getClientMode() === 'alpha';
}

View File

@@ -0,0 +1,99 @@
/**
* Domain Entity: Driver
*
* Represents a driver profile in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
export class Driver {
readonly id: string;
readonly iracingId: string;
readonly name: string;
readonly country: string;
readonly bio?: string;
readonly joinedAt: Date;
private constructor(props: {
id: string;
iracingId: string;
name: string;
country: string;
bio?: string;
joinedAt: Date;
}) {
this.id = props.id;
this.iracingId = props.iracingId;
this.name = props.name;
this.country = props.country;
this.bio = props.bio;
this.joinedAt = props.joinedAt;
}
/**
* Factory method to create a new Driver entity
*/
static create(props: {
id: string;
iracingId: string;
name: string;
country: string;
bio?: string;
joinedAt?: Date;
}): Driver {
this.validate(props);
return new Driver({
...props,
joinedAt: props.joinedAt ?? new Date(),
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
iracingId: string;
name: string;
country: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Driver ID is required');
}
if (!props.iracingId || props.iracingId.trim().length === 0) {
throw new Error('iRacing ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Driver name is required');
}
if (!props.country || props.country.trim().length === 0) {
throw new Error('Country code is required');
}
// Validate ISO country code format (2-3 letters)
if (!/^[A-Z]{2,3}$/i.test(props.country)) {
throw new Error('Country must be a valid ISO code (2-3 letters)');
}
}
/**
* Create a copy with updated properties
*/
update(props: Partial<{
name: string;
country: string;
bio: string;
}>): Driver {
return new Driver({
id: this.id,
iracingId: this.iracingId,
name: props.name ?? this.name,
country: props.country ?? this.country,
bio: props.bio ?? this.bio,
joinedAt: this.joinedAt,
});
}
}

View File

@@ -0,0 +1,115 @@
/**
* Domain Entity: League
*
* Represents a league in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
export interface LeagueSettings {
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
sessionDuration?: number;
qualifyingFormat?: 'single-lap' | 'open';
customPoints?: Record<number, number>;
}
export class League {
readonly id: string;
readonly name: string;
readonly description: string;
readonly ownerId: string;
readonly settings: LeagueSettings;
readonly createdAt: Date;
private constructor(props: {
id: string;
name: string;
description: string;
ownerId: string;
settings: LeagueSettings;
createdAt: Date;
}) {
this.id = props.id;
this.name = props.name;
this.description = props.description;
this.ownerId = props.ownerId;
this.settings = props.settings;
this.createdAt = props.createdAt;
}
/**
* Factory method to create a new League entity
*/
static create(props: {
id: string;
name: string;
description: string;
ownerId: string;
settings?: Partial<LeagueSettings>;
createdAt?: Date;
}): League {
this.validate(props);
const defaultSettings: LeagueSettings = {
pointsSystem: 'f1-2024',
sessionDuration: 60,
qualifyingFormat: 'open',
};
return new League({
id: props.id,
name: props.name,
description: props.description,
ownerId: props.ownerId,
settings: { ...defaultSettings, ...props.settings },
createdAt: props.createdAt ?? new Date(),
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
name: string;
description: string;
ownerId: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('League ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('League name is required');
}
if (props.name.length > 100) {
throw new Error('League name must be 100 characters or less');
}
if (!props.description || props.description.trim().length === 0) {
throw new Error('League description is required');
}
if (!props.ownerId || props.ownerId.trim().length === 0) {
throw new Error('League owner ID is required');
}
}
/**
* Create a copy with updated properties
*/
update(props: Partial<{
name: string;
description: string;
settings: LeagueSettings;
}>): League {
return new League({
id: this.id,
name: props.name ?? this.name,
description: props.description ?? this.description,
ownerId: this.ownerId,
settings: props.settings ?? this.settings,
createdAt: this.createdAt,
});
}
}

View File

@@ -0,0 +1,143 @@
/**
* Domain Entity: Race
*
* Represents a race/session in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
export type SessionType = 'practice' | 'qualifying' | 'race';
export type RaceStatus = 'scheduled' | 'completed' | 'cancelled';
export class Race {
readonly id: string;
readonly leagueId: string;
readonly scheduledAt: Date;
readonly track: string;
readonly car: string;
readonly sessionType: SessionType;
readonly status: RaceStatus;
private constructor(props: {
id: string;
leagueId: string;
scheduledAt: Date;
track: string;
car: string;
sessionType: SessionType;
status: RaceStatus;
}) {
this.id = props.id;
this.leagueId = props.leagueId;
this.scheduledAt = props.scheduledAt;
this.track = props.track;
this.car = props.car;
this.sessionType = props.sessionType;
this.status = props.status;
}
/**
* Factory method to create a new Race entity
*/
static create(props: {
id: string;
leagueId: string;
scheduledAt: Date;
track: string;
car: string;
sessionType?: SessionType;
status?: RaceStatus;
}): Race {
this.validate(props);
return new Race({
id: props.id,
leagueId: props.leagueId,
scheduledAt: props.scheduledAt,
track: props.track,
car: props.car,
sessionType: props.sessionType ?? 'race',
status: props.status ?? 'scheduled',
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
leagueId: string;
scheduledAt: Date;
track: string;
car: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Race ID is required');
}
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new Error('League ID is required');
}
if (!props.scheduledAt || !(props.scheduledAt instanceof Date)) {
throw new Error('Valid scheduled date is required');
}
if (!props.track || props.track.trim().length === 0) {
throw new Error('Track is required');
}
if (!props.car || props.car.trim().length === 0) {
throw new Error('Car is required');
}
}
/**
* Mark race as completed
*/
complete(): Race {
if (this.status === 'completed') {
throw new Error('Race is already completed');
}
if (this.status === 'cancelled') {
throw new Error('Cannot complete a cancelled race');
}
return new Race({
...this,
status: 'completed',
});
}
/**
* Cancel the race
*/
cancel(): Race {
if (this.status === 'completed') {
throw new Error('Cannot cancel a completed race');
}
if (this.status === 'cancelled') {
throw new Error('Race is already cancelled');
}
return new Race({
...this,
status: 'cancelled',
});
}
/**
* Check if race is in the past
*/
isPast(): boolean {
return this.scheduledAt < new Date();
}
/**
* Check if race is upcoming
*/
isUpcoming(): boolean {
return this.status === 'scheduled' && !this.isPast();
}
}

View File

@@ -0,0 +1,113 @@
/**
* Domain Entity: Result
*
* Represents a race result in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
export class Result {
readonly id: string;
readonly raceId: string;
readonly driverId: string;
readonly position: number;
readonly fastestLap: number;
readonly incidents: number;
readonly startPosition: number;
private constructor(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}) {
this.id = props.id;
this.raceId = props.raceId;
this.driverId = props.driverId;
this.position = props.position;
this.fastestLap = props.fastestLap;
this.incidents = props.incidents;
this.startPosition = props.startPosition;
}
/**
* Factory method to create a new Result entity
*/
static create(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}): Result {
this.validate(props);
return new Result(props);
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Result ID is required');
}
if (!props.raceId || props.raceId.trim().length === 0) {
throw new Error('Race ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new Error('Driver ID is required');
}
if (!Number.isInteger(props.position) || props.position < 1) {
throw new Error('Position must be a positive integer');
}
if (props.fastestLap < 0) {
throw new Error('Fastest lap cannot be negative');
}
if (!Number.isInteger(props.incidents) || props.incidents < 0) {
throw new Error('Incidents must be a non-negative integer');
}
if (!Number.isInteger(props.startPosition) || props.startPosition < 1) {
throw new Error('Start position must be a positive integer');
}
}
/**
* Calculate positions gained/lost
*/
getPositionChange(): number {
return this.startPosition - this.position;
}
/**
* Check if driver finished on podium
*/
isPodium(): boolean {
return this.position <= 3;
}
/**
* Check if driver had a clean race (0 incidents)
*/
isClean(): boolean {
return this.incidents === 0;
}
}

View File

@@ -0,0 +1,117 @@
/**
* Domain Entity: Standing
*
* Represents a championship standing in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
export class Standing {
readonly leagueId: string;
readonly driverId: string;
readonly points: number;
readonly wins: number;
readonly position: number;
readonly racesCompleted: number;
private constructor(props: {
leagueId: string;
driverId: string;
points: number;
wins: number;
position: number;
racesCompleted: number;
}) {
this.leagueId = props.leagueId;
this.driverId = props.driverId;
this.points = props.points;
this.wins = props.wins;
this.position = props.position;
this.racesCompleted = props.racesCompleted;
}
/**
* Factory method to create a new Standing entity
*/
static create(props: {
leagueId: string;
driverId: string;
points?: number;
wins?: number;
position?: number;
racesCompleted?: number;
}): Standing {
this.validate(props);
return new Standing({
leagueId: props.leagueId,
driverId: props.driverId,
points: props.points ?? 0,
wins: props.wins ?? 0,
position: props.position ?? 0,
racesCompleted: props.racesCompleted ?? 0,
});
}
/**
* Domain validation logic
*/
private static validate(props: {
leagueId: string;
driverId: string;
}): void {
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new Error('League ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new Error('Driver ID is required');
}
}
/**
* Add points from a race result
*/
addRaceResult(position: number, pointsSystem: Record<number, number>): Standing {
const racePoints = pointsSystem[position] ?? 0;
const isWin = position === 1;
return new Standing({
leagueId: this.leagueId,
driverId: this.driverId,
points: this.points + racePoints,
wins: this.wins + (isWin ? 1 : 0),
position: this.position,
racesCompleted: this.racesCompleted + 1,
});
}
/**
* Update championship position
*/
updatePosition(position: number): Standing {
if (!Number.isInteger(position) || position < 1) {
throw new Error('Position must be a positive integer');
}
return new Standing({
...this,
position,
});
}
/**
* Calculate average points per race
*/
getAveragePoints(): number {
if (this.racesCompleted === 0) return 0;
return this.points / this.racesCompleted;
}
/**
* Calculate win percentage
*/
getWinPercentage(): number {
if (this.racesCompleted === 0) return 0;
return (this.wins / this.racesCompleted) * 100;
}
}

8
apps/website/env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
declare namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_GRIDPILOT_MODE?: 'prelaunch' | 'alpha';
}
}

View File

@@ -0,0 +1,86 @@
/**
* Infrastructure Adapter: InMemoryDriverRepository
*
* In-memory implementation of IDriverRepository.
* Stores data in Map structure with UUID generation.
*/
import { v4 as uuidv4 } from 'uuid';
import { Driver } from '../../domain/entities/Driver';
import { IDriverRepository } from '../../application/ports/IDriverRepository';
export class InMemoryDriverRepository implements IDriverRepository {
private drivers: Map<string, Driver>;
constructor(seedData?: Driver[]) {
this.drivers = new Map();
if (seedData) {
seedData.forEach(driver => {
this.drivers.set(driver.id, driver);
});
}
}
async findById(id: string): Promise<Driver | null> {
return this.drivers.get(id) ?? null;
}
async findByIRacingId(iracingId: string): Promise<Driver | null> {
const driver = Array.from(this.drivers.values()).find(
d => d.iracingId === iracingId
);
return driver ?? null;
}
async findAll(): Promise<Driver[]> {
return Array.from(this.drivers.values());
}
async create(driver: Driver): Promise<Driver> {
if (await this.exists(driver.id)) {
throw new Error(`Driver with ID ${driver.id} already exists`);
}
if (await this.existsByIRacingId(driver.iracingId)) {
throw new Error(`Driver with iRacing ID ${driver.iracingId} already exists`);
}
this.drivers.set(driver.id, driver);
return driver;
}
async update(driver: Driver): Promise<Driver> {
if (!await this.exists(driver.id)) {
throw new Error(`Driver with ID ${driver.id} not found`);
}
this.drivers.set(driver.id, driver);
return driver;
}
async delete(id: string): Promise<void> {
if (!await this.exists(id)) {
throw new Error(`Driver with ID ${id} not found`);
}
this.drivers.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.drivers.has(id);
}
async existsByIRacingId(iracingId: string): Promise<boolean> {
return Array.from(this.drivers.values()).some(
d => d.iracingId === iracingId
);
}
/**
* Utility method to generate a new UUID
*/
static generateId(): string {
return uuidv4();
}
}

View File

@@ -0,0 +1,82 @@
/**
* Infrastructure Adapter: InMemoryLeagueRepository
*
* In-memory implementation of ILeagueRepository.
* Stores data in Map structure with UUID generation.
*/
import { v4 as uuidv4 } from 'uuid';
import { League } from '../../domain/entities/League';
import { ILeagueRepository } from '../../application/ports/ILeagueRepository';
export class InMemoryLeagueRepository implements ILeagueRepository {
private leagues: Map<string, League>;
constructor(seedData?: League[]) {
this.leagues = new Map();
if (seedData) {
seedData.forEach(league => {
this.leagues.set(league.id, league);
});
}
}
async findById(id: string): Promise<League | null> {
return this.leagues.get(id) ?? null;
}
async findAll(): Promise<League[]> {
return Array.from(this.leagues.values());
}
async findByOwnerId(ownerId: string): Promise<League[]> {
return Array.from(this.leagues.values()).filter(
league => league.ownerId === ownerId
);
}
async create(league: League): Promise<League> {
if (await this.exists(league.id)) {
throw new Error(`League with ID ${league.id} already exists`);
}
this.leagues.set(league.id, league);
return league;
}
async update(league: League): Promise<League> {
if (!await this.exists(league.id)) {
throw new Error(`League with ID ${league.id} not found`);
}
this.leagues.set(league.id, league);
return league;
}
async delete(id: string): Promise<void> {
if (!await this.exists(id)) {
throw new Error(`League with ID ${id} not found`);
}
this.leagues.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.leagues.has(id);
}
async searchByName(query: string): Promise<League[]> {
const normalizedQuery = query.toLowerCase();
return Array.from(this.leagues.values()).filter(league =>
league.name.toLowerCase().includes(normalizedQuery)
);
}
/**
* Utility method to generate a new UUID
*/
static generateId(): string {
return uuidv4();
}
}

View File

@@ -0,0 +1,110 @@
/**
* Infrastructure Adapter: InMemoryRaceRepository
*
* In-memory implementation of IRaceRepository.
* Stores data in Map structure with UUID generation.
*/
import { v4 as uuidv4 } from 'uuid';
import { Race, RaceStatus } from '../../domain/entities/Race';
import { IRaceRepository } from '../../application/ports/IRaceRepository';
export class InMemoryRaceRepository implements IRaceRepository {
private races: Map<string, Race>;
constructor(seedData?: Race[]) {
this.races = new Map();
if (seedData) {
seedData.forEach(race => {
this.races.set(race.id, race);
});
}
}
async findById(id: string): Promise<Race | null> {
return this.races.get(id) ?? null;
}
async findAll(): Promise<Race[]> {
return Array.from(this.races.values());
}
async findByLeagueId(leagueId: string): Promise<Race[]> {
return Array.from(this.races.values())
.filter(race => race.leagueId === leagueId)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
}
async findUpcomingByLeagueId(leagueId: string): Promise<Race[]> {
const now = new Date();
return Array.from(this.races.values())
.filter(race =>
race.leagueId === leagueId &&
race.status === 'scheduled' &&
race.scheduledAt > now
)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
}
async findCompletedByLeagueId(leagueId: string): Promise<Race[]> {
return Array.from(this.races.values())
.filter(race =>
race.leagueId === leagueId &&
race.status === 'completed'
)
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime());
}
async findByStatus(status: RaceStatus): Promise<Race[]> {
return Array.from(this.races.values())
.filter(race => race.status === status)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
}
async findByDateRange(startDate: Date, endDate: Date): Promise<Race[]> {
return Array.from(this.races.values())
.filter(race =>
race.scheduledAt >= startDate &&
race.scheduledAt <= endDate
)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
}
async create(race: Race): Promise<Race> {
if (await this.exists(race.id)) {
throw new Error(`Race with ID ${race.id} already exists`);
}
this.races.set(race.id, race);
return race;
}
async update(race: Race): Promise<Race> {
if (!await this.exists(race.id)) {
throw new Error(`Race with ID ${race.id} not found`);
}
this.races.set(race.id, race);
return race;
}
async delete(id: string): Promise<void> {
if (!await this.exists(id)) {
throw new Error(`Race with ID ${id} not found`);
}
this.races.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.races.has(id);
}
/**
* Utility method to generate a new UUID
*/
static generateId(): string {
return uuidv4();
}
}

View File

@@ -0,0 +1,125 @@
/**
* Infrastructure Adapter: InMemoryResultRepository
*
* In-memory implementation of IResultRepository.
* Stores data in Map structure with UUID generation.
*/
import { v4 as uuidv4 } from 'uuid';
import { Result } from '../../domain/entities/Result';
import { IResultRepository } from '../../application/ports/IResultRepository';
import { IRaceRepository } from '../../application/ports/IRaceRepository';
export class InMemoryResultRepository implements IResultRepository {
private results: Map<string, Result>;
private raceRepository?: IRaceRepository;
constructor(seedData?: Result[], raceRepository?: IRaceRepository) {
this.results = new Map();
this.raceRepository = raceRepository;
if (seedData) {
seedData.forEach(result => {
this.results.set(result.id, result);
});
}
}
async findById(id: string): Promise<Result | null> {
return this.results.get(id) ?? null;
}
async findAll(): Promise<Result[]> {
return Array.from(this.results.values());
}
async findByRaceId(raceId: string): Promise<Result[]> {
return Array.from(this.results.values())
.filter(result => result.raceId === raceId)
.sort((a, b) => a.position - b.position);
}
async findByDriverId(driverId: string): Promise<Result[]> {
return Array.from(this.results.values())
.filter(result => result.driverId === driverId);
}
async findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Result[]> {
if (!this.raceRepository) {
return [];
}
const leagueRaces = await this.raceRepository.findByLeagueId(leagueId);
const leagueRaceIds = new Set(leagueRaces.map(race => race.id));
return Array.from(this.results.values())
.filter(result =>
result.driverId === driverId &&
leagueRaceIds.has(result.raceId)
);
}
async create(result: Result): Promise<Result> {
if (await this.exists(result.id)) {
throw new Error(`Result with ID ${result.id} already exists`);
}
this.results.set(result.id, result);
return result;
}
async createMany(results: Result[]): Promise<Result[]> {
const created: Result[] = [];
for (const result of results) {
if (await this.exists(result.id)) {
throw new Error(`Result with ID ${result.id} already exists`);
}
this.results.set(result.id, result);
created.push(result);
}
return created;
}
async update(result: Result): Promise<Result> {
if (!await this.exists(result.id)) {
throw new Error(`Result with ID ${result.id} not found`);
}
this.results.set(result.id, result);
return result;
}
async delete(id: string): Promise<void> {
if (!await this.exists(id)) {
throw new Error(`Result with ID ${id} not found`);
}
this.results.delete(id);
}
async deleteByRaceId(raceId: string): Promise<void> {
const raceResults = await this.findByRaceId(raceId);
raceResults.forEach(result => {
this.results.delete(result.id);
});
}
async exists(id: string): Promise<boolean> {
return this.results.has(id);
}
async existsByRaceId(raceId: string): Promise<boolean> {
return Array.from(this.results.values()).some(
result => result.raceId === raceId
);
}
/**
* Utility method to generate a new UUID
*/
static generateId(): string {
return uuidv4();
}
}

View File

@@ -0,0 +1,188 @@
/**
* Infrastructure Adapter: InMemoryStandingRepository
*
* In-memory implementation of IStandingRepository.
* Stores data in Map structure and calculates standings from race results.
*/
import { Standing } from '../../domain/entities/Standing';
import { IStandingRepository } from '../../application/ports/IStandingRepository';
import { IResultRepository } from '../../application/ports/IResultRepository';
import { IRaceRepository } from '../../application/ports/IRaceRepository';
import { ILeagueRepository } from '../../application/ports/ILeagueRepository';
/**
* Points systems presets
*/
const POINTS_SYSTEMS: Record<string, Record<number, number>> = {
'f1-2024': {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10,
6: 8, 7: 6, 8: 4, 9: 2, 10: 1
},
'indycar': {
1: 50, 2: 40, 3: 35, 4: 32, 5: 30,
6: 28, 7: 26, 8: 24, 9: 22, 10: 20,
11: 19, 12: 18, 13: 17, 14: 16, 15: 15
}
};
export class InMemoryStandingRepository implements IStandingRepository {
private standings: Map<string, Standing>;
private resultRepository?: IResultRepository;
private raceRepository?: IRaceRepository;
private leagueRepository?: ILeagueRepository;
constructor(
seedData?: Standing[],
resultRepository?: IResultRepository,
raceRepository?: IRaceRepository,
leagueRepository?: ILeagueRepository
) {
this.standings = new Map();
this.resultRepository = resultRepository;
this.raceRepository = raceRepository;
this.leagueRepository = leagueRepository;
if (seedData) {
seedData.forEach(standing => {
const key = this.getKey(standing.leagueId, standing.driverId);
this.standings.set(key, standing);
});
}
}
private getKey(leagueId: string, driverId: string): string {
return `${leagueId}:${driverId}`;
}
async findByLeagueId(leagueId: string): Promise<Standing[]> {
return Array.from(this.standings.values())
.filter(standing => standing.leagueId === leagueId)
.sort((a, b) => {
// Sort by position (lower is better)
if (a.position !== b.position) {
return a.position - b.position;
}
// If positions are equal, sort by points (higher is better)
return b.points - a.points;
});
}
async findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Standing | null> {
const key = this.getKey(leagueId, driverId);
return this.standings.get(key) ?? null;
}
async findAll(): Promise<Standing[]> {
return Array.from(this.standings.values());
}
async save(standing: Standing): Promise<Standing> {
const key = this.getKey(standing.leagueId, standing.driverId);
this.standings.set(key, standing);
return standing;
}
async saveMany(standings: Standing[]): Promise<Standing[]> {
standings.forEach(standing => {
const key = this.getKey(standing.leagueId, standing.driverId);
this.standings.set(key, standing);
});
return standings;
}
async delete(leagueId: string, driverId: string): Promise<void> {
const key = this.getKey(leagueId, driverId);
this.standings.delete(key);
}
async deleteByLeagueId(leagueId: string): Promise<void> {
const toDelete = Array.from(this.standings.values())
.filter(standing => standing.leagueId === leagueId);
toDelete.forEach(standing => {
const key = this.getKey(standing.leagueId, standing.driverId);
this.standings.delete(key);
});
}
async exists(leagueId: string, driverId: string): Promise<boolean> {
const key = this.getKey(leagueId, driverId);
return this.standings.has(key);
}
async recalculate(leagueId: string): Promise<Standing[]> {
if (!this.resultRepository || !this.raceRepository || !this.leagueRepository) {
throw new Error('Cannot recalculate standings: missing required repositories');
}
// Get league to determine points system
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
throw new Error(`League with ID ${leagueId} not found`);
}
// Get points system
const pointsSystem = league.settings.customPoints ??
POINTS_SYSTEMS[league.settings.pointsSystem] ??
POINTS_SYSTEMS['f1-2024'];
// Get all completed races for the league
const races = await this.raceRepository.findCompletedByLeagueId(leagueId);
// Get all results for these races
const allResults = await Promise.all(
races.map(race => this.resultRepository!.findByRaceId(race.id))
);
const results = allResults.flat();
// Calculate standings per driver
const standingsMap = new Map<string, Standing>();
results.forEach(result => {
let standing = standingsMap.get(result.driverId);
if (!standing) {
standing = Standing.create({
leagueId,
driverId: result.driverId,
});
}
// Add points from this result
standing = standing.addRaceResult(result.position, pointsSystem);
standingsMap.set(result.driverId, standing);
});
// Sort by points and assign positions
const sortedStandings = Array.from(standingsMap.values())
.sort((a, b) => {
if (b.points !== a.points) {
return b.points - a.points;
}
// Tie-breaker: most wins
if (b.wins !== a.wins) {
return b.wins - a.wins;
}
// Tie-breaker: most races completed
return b.racesCompleted - a.racesCompleted;
});
// Assign positions
const updatedStandings = sortedStandings.map((standing, index) =>
standing.updatePosition(index + 1)
);
// Save all standings
await this.saveMany(updatedStandings);
return updatedStandings;
}
/**
* Get available points systems
*/
static getPointsSystems(): Record<string, Record<number, number>> {
return POINTS_SYSTEMS;
}
}

View File

@@ -0,0 +1,187 @@
/**
* Dependency Injection Container
*
* Initializes all in-memory repositories and provides accessor functions.
* Allows easy swapping to persistent repositories later.
*/
import { Driver } from '../domain/entities/Driver';
import { League } from '../domain/entities/League';
import { Race } from '../domain/entities/Race';
import { Result } from '../domain/entities/Result';
import { Standing } from '../domain/entities/Standing';
import { IDriverRepository } from '../application/ports/IDriverRepository';
import { ILeagueRepository } from '../application/ports/ILeagueRepository';
import { IRaceRepository } from '../application/ports/IRaceRepository';
import { IResultRepository } from '../application/ports/IResultRepository';
import { IStandingRepository } from '../application/ports/IStandingRepository';
import { InMemoryDriverRepository } from '../infrastructure/repositories/InMemoryDriverRepository';
import { InMemoryLeagueRepository } from '../infrastructure/repositories/InMemoryLeagueRepository';
import { InMemoryRaceRepository } from '../infrastructure/repositories/InMemoryRaceRepository';
import { InMemoryResultRepository } from '../infrastructure/repositories/InMemoryResultRepository';
import { InMemoryStandingRepository } from '../infrastructure/repositories/InMemoryStandingRepository';
/**
* Seed data for development
*/
function createSeedData() {
// Create a sample driver
const driver1 = Driver.create({
id: '550e8400-e29b-41d4-a716-446655440001',
iracingId: '123456',
name: 'Max Verstappen',
country: 'NL',
bio: 'Three-time world champion',
joinedAt: new Date('2024-01-15'),
});
// Create a sample league
const league1 = League.create({
id: '550e8400-e29b-41d4-a716-446655440002',
name: 'European GT Championship',
description: 'Weekly GT3 racing with professional drivers',
ownerId: driver1.id,
settings: {
pointsSystem: 'f1-2024',
sessionDuration: 60,
qualifyingFormat: 'open',
},
createdAt: new Date('2024-01-20'),
});
// Create sample races
const race1 = Race.create({
id: '550e8400-e29b-41d4-a716-446655440003',
leagueId: league1.id,
scheduledAt: new Date('2024-03-15T19:00:00Z'),
track: 'Monza GP',
car: 'Porsche 911 GT3 R',
sessionType: 'race',
status: 'completed',
});
const race2 = Race.create({
id: '550e8400-e29b-41d4-a716-446655440004',
leagueId: league1.id,
scheduledAt: new Date('2024-03-22T19:00:00Z'),
track: 'Spa-Francorchamps',
car: 'Porsche 911 GT3 R',
sessionType: 'race',
status: 'scheduled',
});
return {
drivers: [driver1],
leagues: [league1],
races: [race1, race2],
};
}
/**
* DI Container class
*/
class DIContainer {
private static instance: DIContainer;
private _driverRepository: IDriverRepository;
private _leagueRepository: ILeagueRepository;
private _raceRepository: IRaceRepository;
private _resultRepository: IResultRepository;
private _standingRepository: IStandingRepository;
private constructor() {
// Create seed data
const seedData = createSeedData();
// Initialize repositories with seed data
this._driverRepository = new InMemoryDriverRepository(seedData.drivers);
this._leagueRepository = new InMemoryLeagueRepository(seedData.leagues);
this._raceRepository = new InMemoryRaceRepository(seedData.races);
// Result repository needs race repository for league-based queries
this._resultRepository = new InMemoryResultRepository(
undefined,
this._raceRepository
);
// Standing repository needs all three for recalculation
this._standingRepository = new InMemoryStandingRepository(
undefined,
this._resultRepository,
this._raceRepository,
this._leagueRepository
);
}
/**
* Get singleton instance
*/
static getInstance(): DIContainer {
if (!DIContainer.instance) {
DIContainer.instance = new DIContainer();
}
return DIContainer.instance;
}
/**
* Reset the container (useful for testing)
*/
static reset(): void {
DIContainer.instance = new DIContainer();
}
/**
* Repository getters
*/
get driverRepository(): IDriverRepository {
return this._driverRepository;
}
get leagueRepository(): ILeagueRepository {
return this._leagueRepository;
}
get raceRepository(): IRaceRepository {
return this._raceRepository;
}
get resultRepository(): IResultRepository {
return this._resultRepository;
}
get standingRepository(): IStandingRepository {
return this._standingRepository;
}
}
/**
* Exported accessor functions
*/
export function getDriverRepository(): IDriverRepository {
return DIContainer.getInstance().driverRepository;
}
export function getLeagueRepository(): ILeagueRepository {
return DIContainer.getInstance().leagueRepository;
}
export function getRaceRepository(): IRaceRepository {
return DIContainer.getInstance().raceRepository;
}
export function getResultRepository(): IResultRepository {
return DIContainer.getInstance().resultRepository;
}
export function getStandingRepository(): IStandingRepository {
return DIContainer.getInstance().standingRepository;
}
/**
* Reset function for testing
*/
export function resetContainer(): void {
DIContainer.reset();
}

View File

@@ -1,13 +1,13 @@
/**
* Mode detection system for GridPilot website
*
* Controls whether the site shows pre-launch content or full platform
* Based on GRIDPILOT_MODE environment variable
* Controls whether the site shows pre-launch content or alpha platform
* Based on NEXT_PUBLIC_GRIDPILOT_MODE environment variable
*/
export type AppMode = 'pre-launch' | 'post-launch';
export type AppMode = 'pre-launch' | 'alpha';
const VALID_MODES: readonly AppMode[] = ['pre-launch', 'post-launch'] as const;
const VALID_MODES: readonly AppMode[] = ['pre-launch', 'alpha'] as const;
/**
* Get the current application mode from environment variable
@@ -17,7 +17,7 @@ const VALID_MODES: readonly AppMode[] = ['pre-launch', 'post-launch'] as const;
* @returns {AppMode} The current application mode
*/
export function getAppMode(): AppMode {
const mode = process.env.GRIDPILOT_MODE;
const mode = process.env.NEXT_PUBLIC_GRIDPILOT_MODE;
if (!mode) {
return 'pre-launch';
@@ -25,7 +25,7 @@ export function getAppMode(): AppMode {
if (!isValidMode(mode)) {
const validModes = VALID_MODES.join(', ');
const error = `Invalid GRIDPILOT_MODE: "${mode}". Must be one of: ${validModes}`;
const error = `Invalid NEXT_PUBLIC_GRIDPILOT_MODE: "${mode}". Must be one of: ${validModes}`;
if (process.env.NODE_ENV === 'development') {
throw new Error(error);
@@ -53,10 +53,10 @@ export function isPreLaunch(): boolean {
}
/**
* Check if currently in post-launch mode
* Check if currently in alpha mode
*/
export function isPostLaunch(): boolean {
return getAppMode() === 'post-launch';
export function isAlpha(): boolean {
return getAppMode() === 'alpha';
}
/**

View File

@@ -9,15 +9,15 @@ import { getAppMode, isPublicRoute } from './lib/mode';
* - Only allows access to public routes (/, /api/signup)
* - Returns 404 for all other routes
*
* In post-launch mode:
* In alpha mode:
* - All routes are accessible
*/
export function middleware(request: NextRequest) {
const mode = getAppMode();
const { pathname } = request.nextUrl;
// In post-launch mode, allow all routes
if (mode === 'post-launch') {
// In alpha mode, allow all routes
if (mode === 'alpha') {
return NextResponse.next();
}

1063
docs/ALPHA_PLAN.md Normal file

File diff suppressed because it is too large Load Diff