alpha wip
This commit is contained in:
@@ -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 3–5 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 (RED → GREEN → Refactor)
|
||||
- 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.
|
||||
186
.roo/rules.md
186
.roo/rules.md
@@ -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 1–2 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 3–5 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
236
apps/website/app/leagues/[id]/page.tsx
Normal file
236
apps/website/app/leagues/[id]/page.tsx
Normal 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 “Schedule Race” 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>
|
||||
);
|
||||
}
|
||||
134
apps/website/app/leagues/[id]/standings/page.tsx
Normal file
134
apps/website/app/leagues/[id]/standings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
apps/website/app/leagues/page.tsx
Normal file
120
apps/website/app/leagues/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
43
apps/website/app/profile/page.tsx
Normal file
43
apps/website/app/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
277
apps/website/app/races/[id]/page.tsx
Normal file
277
apps/website/app/races/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
apps/website/app/races/[id]/results/page.tsx
Normal file
247
apps/website/app/races/[id]/results/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
apps/website/app/races/page.tsx
Normal file
224
apps/website/app/races/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
apps/website/application/mappers/EntityMappers.ts
Normal file
174
apps/website/application/mappers/EntityMappers.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
50
apps/website/application/ports/IDriverRepository.ts
Normal file
50
apps/website/application/ports/IDriverRepository.ts
Normal 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>;
|
||||
}
|
||||
50
apps/website/application/ports/ILeagueRepository.ts
Normal file
50
apps/website/application/ports/ILeagueRepository.ts
Normal 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[]>;
|
||||
}
|
||||
65
apps/website/application/ports/IRaceRepository.ts
Normal file
65
apps/website/application/ports/IRaceRepository.ts
Normal 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>;
|
||||
}
|
||||
70
apps/website/application/ports/IResultRepository.ts
Normal file
70
apps/website/application/ports/IResultRepository.ts
Normal 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>;
|
||||
}
|
||||
55
apps/website/application/ports/IStandingRepository.ts
Normal file
55
apps/website/application/ports/IStandingRepository.ts
Normal 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[]>;
|
||||
}
|
||||
50
apps/website/components/alpha/AlphaBanner.tsx
Normal file
50
apps/website/components/alpha/AlphaBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/website/components/alpha/AlphaFooter.tsx
Normal file
41
apps/website/components/alpha/AlphaFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/website/components/alpha/AlphaNav.tsx
Normal file
57
apps/website/components/alpha/AlphaNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
apps/website/components/alpha/CompanionInstructions.tsx
Normal file
128
apps/website/components/alpha/CompanionInstructions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/website/components/alpha/CompanionStatus.tsx
Normal file
27
apps/website/components/alpha/CompanionStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
apps/website/components/alpha/CreateDriverForm.tsx
Normal file
187
apps/website/components/alpha/CreateDriverForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
195
apps/website/components/alpha/CreateLeagueForm.tsx
Normal file
195
apps/website/components/alpha/CreateLeagueForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
apps/website/components/alpha/DataWarning.tsx
Normal file
52
apps/website/components/alpha/DataWarning.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/website/components/alpha/DriverProfile.tsx
Normal file
87
apps/website/components/alpha/DriverProfile.tsx
Normal 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 '🏁';
|
||||
}
|
||||
23
apps/website/components/alpha/FeatureLimitationTooltip.tsx
Normal file
23
apps/website/components/alpha/FeatureLimitationTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
apps/website/components/alpha/ImportResultsForm.tsx
Normal file
196
apps/website/components/alpha/ImportResultsForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
apps/website/components/alpha/LeagueCard.tsx
Normal file
42
apps/website/components/alpha/LeagueCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/website/components/alpha/RaceCard.tsx
Normal file
87
apps/website/components/alpha/RaceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
apps/website/components/alpha/ResultsTable.tsx
Normal file
103
apps/website/components/alpha/ResultsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
313
apps/website/components/alpha/ScheduleRaceForm.tsx
Normal file
313
apps/website/components/alpha/ScheduleRaceForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
apps/website/components/alpha/StandingsTable.tsx
Normal file
72
apps/website/components/alpha/StandingsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
99
apps/website/domain/entities/Driver.ts
Normal file
99
apps/website/domain/entities/Driver.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
115
apps/website/domain/entities/League.ts
Normal file
115
apps/website/domain/entities/League.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
143
apps/website/domain/entities/Race.ts
Normal file
143
apps/website/domain/entities/Race.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
113
apps/website/domain/entities/Result.ts
Normal file
113
apps/website/domain/entities/Result.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
117
apps/website/domain/entities/Standing.ts
Normal file
117
apps/website/domain/entities/Standing.ts
Normal 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
8
apps/website/env.d.ts
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
187
apps/website/lib/di-container.ts
Normal file
187
apps/website/lib/di-container.ts
Normal 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();
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
1063
docs/ALPHA_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user