From 97e29d3d800edafd2ba46d28ce498807fb2dde7c Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 3 Dec 2025 00:46:08 +0100 Subject: [PATCH] alpha wip --- .roo/rules-code/rules.md | 107 +- .roo/rules.md | 186 ++- apps/website/.env.example | 14 +- apps/website/README.md | 21 +- apps/website/app/layout.tsx | 24 + apps/website/app/leagues/[id]/page.tsx | 236 ++++ .../app/leagues/[id]/standings/page.tsx | 134 +++ apps/website/app/leagues/page.tsx | 120 ++ apps/website/app/page.tsx | 293 ++++- apps/website/app/profile/page.tsx | 43 + apps/website/app/races/[id]/page.tsx | 277 +++++ apps/website/app/races/[id]/results/page.tsx | 247 ++++ apps/website/app/races/page.tsx | 224 ++++ .../application/mappers/EntityMappers.ts | 174 +++ .../application/ports/IDriverRepository.ts | 50 + .../application/ports/ILeagueRepository.ts | 50 + .../application/ports/IRaceRepository.ts | 65 + .../application/ports/IResultRepository.ts | 70 ++ .../application/ports/IStandingRepository.ts | 55 + apps/website/components/alpha/AlphaBanner.tsx | 50 + apps/website/components/alpha/AlphaFooter.tsx | 41 + apps/website/components/alpha/AlphaNav.tsx | 57 + .../alpha/CompanionInstructions.tsx | 128 ++ .../components/alpha/CompanionStatus.tsx | 27 + .../components/alpha/CreateDriverForm.tsx | 187 +++ .../components/alpha/CreateLeagueForm.tsx | 195 +++ apps/website/components/alpha/DataWarning.tsx | 52 + .../components/alpha/DriverProfile.tsx | 87 ++ .../alpha/FeatureLimitationTooltip.tsx | 23 + .../components/alpha/ImportResultsForm.tsx | 196 +++ apps/website/components/alpha/LeagueCard.tsx | 42 + apps/website/components/alpha/RaceCard.tsx | 87 ++ .../website/components/alpha/ResultsTable.tsx | 103 ++ .../components/alpha/ScheduleRaceForm.tsx | 313 +++++ .../components/alpha/StandingsTable.tsx | 72 ++ apps/website/components/shared/ModeGuard.tsx | 42 +- apps/website/domain/entities/Driver.ts | 99 ++ apps/website/domain/entities/League.ts | 115 ++ apps/website/domain/entities/Race.ts | 143 +++ apps/website/domain/entities/Result.ts | 113 ++ apps/website/domain/entities/Standing.ts | 117 ++ apps/website/env.d.ts | 8 + .../repositories/InMemoryDriverRepository.ts | 86 ++ .../repositories/InMemoryLeagueRepository.ts | 82 ++ .../repositories/InMemoryRaceRepository.ts | 110 ++ .../repositories/InMemoryResultRepository.ts | 125 ++ .../InMemoryStandingRepository.ts | 188 +++ apps/website/lib/di-container.ts | 187 +++ apps/website/lib/mode.ts | 20 +- apps/website/middleware.ts | 10 +- docs/ALPHA_PLAN.md | 1063 +++++++++++++++++ 51 files changed, 6321 insertions(+), 237 deletions(-) create mode 100644 apps/website/app/leagues/[id]/page.tsx create mode 100644 apps/website/app/leagues/[id]/standings/page.tsx create mode 100644 apps/website/app/leagues/page.tsx create mode 100644 apps/website/app/profile/page.tsx create mode 100644 apps/website/app/races/[id]/page.tsx create mode 100644 apps/website/app/races/[id]/results/page.tsx create mode 100644 apps/website/app/races/page.tsx create mode 100644 apps/website/application/mappers/EntityMappers.ts create mode 100644 apps/website/application/ports/IDriverRepository.ts create mode 100644 apps/website/application/ports/ILeagueRepository.ts create mode 100644 apps/website/application/ports/IRaceRepository.ts create mode 100644 apps/website/application/ports/IResultRepository.ts create mode 100644 apps/website/application/ports/IStandingRepository.ts create mode 100644 apps/website/components/alpha/AlphaBanner.tsx create mode 100644 apps/website/components/alpha/AlphaFooter.tsx create mode 100644 apps/website/components/alpha/AlphaNav.tsx create mode 100644 apps/website/components/alpha/CompanionInstructions.tsx create mode 100644 apps/website/components/alpha/CompanionStatus.tsx create mode 100644 apps/website/components/alpha/CreateDriverForm.tsx create mode 100644 apps/website/components/alpha/CreateLeagueForm.tsx create mode 100644 apps/website/components/alpha/DataWarning.tsx create mode 100644 apps/website/components/alpha/DriverProfile.tsx create mode 100644 apps/website/components/alpha/FeatureLimitationTooltip.tsx create mode 100644 apps/website/components/alpha/ImportResultsForm.tsx create mode 100644 apps/website/components/alpha/LeagueCard.tsx create mode 100644 apps/website/components/alpha/RaceCard.tsx create mode 100644 apps/website/components/alpha/ResultsTable.tsx create mode 100644 apps/website/components/alpha/ScheduleRaceForm.tsx create mode 100644 apps/website/components/alpha/StandingsTable.tsx create mode 100644 apps/website/domain/entities/Driver.ts create mode 100644 apps/website/domain/entities/League.ts create mode 100644 apps/website/domain/entities/Race.ts create mode 100644 apps/website/domain/entities/Result.ts create mode 100644 apps/website/domain/entities/Standing.ts create mode 100644 apps/website/env.d.ts create mode 100644 apps/website/infrastructure/repositories/InMemoryDriverRepository.ts create mode 100644 apps/website/infrastructure/repositories/InMemoryLeagueRepository.ts create mode 100644 apps/website/infrastructure/repositories/InMemoryRaceRepository.ts create mode 100644 apps/website/infrastructure/repositories/InMemoryResultRepository.ts create mode 100644 apps/website/infrastructure/repositories/InMemoryStandingRepository.ts create mode 100644 apps/website/lib/di-container.ts create mode 100644 docs/ALPHA_PLAN.md diff --git a/.roo/rules-code/rules.md b/.roo/rules-code/rules.md index 833a58e6f..86117f04e 100644 --- a/.roo/rules-code/rules.md +++ b/.roo/rules-code/rules.md @@ -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 speculative abstractions. +- 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 context applied - -Nothing else. \ No newline at end of file +- what moved +- what context applied \ No newline at end of file diff --git a/.roo/rules.md b/.roo/rules.md index f95b55dec..de7870ae3 100644 --- a/.roo/rules.md +++ b/.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. \ No newline at end of file +- Expert completes objective. +- Relevant tests pass. +- No leftover scaffolding. +- Architecture/code aligned. +- attempt_completion emitted. +- Environment reproduces cleanly. +- Workspace stable. \ No newline at end of file diff --git a/apps/website/.env.example b/apps/website/.env.example index c16bb5733..411d9d5bb 100644 --- a/apps/website/.env.example +++ b/apps/website/.env.example @@ -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 \ No newline at end of file +# Example for alpha mode: +# NEXT_PUBLIC_GRIDPILOT_MODE=alpha \ No newline at end of file diff --git a/apps/website/README.md b/apps/website/README.md index bef46df7e..f9ce34d78 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -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= KV_REST_API_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` +**Solution**: +1. Ensure `NEXT_PUBLIC_GRIDPILOT_MODE` is set to `alpha` 2. Restart the development server or redeploy to Vercel 3. Clear browser cache diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index 068cabcf3..4e3e96042 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -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 ( + + + + + + + +
+ {children} +
+ + + + ); + } + return ( diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx new file mode 100644 index 000000000..4955931bf --- /dev/null +++ b/apps/website/app/leagues/[id]/page.tsx @@ -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(null); + const [owner, setOwner] = useState(null); + const [races, setRaces] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
Loading league...
+
+
+ ); + } + + if (error || !league) { + return ( +
+
+ +
+ {error || 'League not found'} +
+ +
+
+
+ ); + } + + const upcomingRaces = races.filter(race => race.status === 'scheduled'); + + return ( +
+
+ {/* Breadcrumb */} +
+ +
+ + {/* League Header */} +
+
+

{league.name}

+ + + Alpha: Single League + + +
+

{league.description}

+
+ +
+ {/* League Info */} + +

League Information

+ +
+
+ +

{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}

+
+ +
+ +

+ {new Date(league.createdAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + })} +

+
+ +
+

League Settings

+ +
+
+ +

{league.settings.pointsSystem.toUpperCase()}

+
+ +
+ +

{league.settings.sessionDuration} minutes

+
+ +
+ +

{league.settings.qualifyingFormat}

+
+
+
+
+
+ + {/* Quick Actions */} + +

Quick Actions

+ +
+ + + +
+
+
+ + {/* Upcoming Races */} + +

Upcoming Races

+ + {upcomingRaces.length === 0 ? ( +
+

No upcoming races scheduled

+

Click “Schedule Race” to create your first race

+
+ ) : ( +
+ {upcomingRaces.map((race) => ( +
router.push(`/races/${race.id}`)} + > +
+
+

{race.track}

+

{race.car}

+

{race.sessionType}

+
+
+

+ {new Date(race.scheduledAt).toLocaleDateString()} +

+

+ {new Date(race.scheduledAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + })} +

+
+
+
+ ))} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/standings/page.tsx b/apps/website/app/leagues/[id]/standings/page.tsx new file mode 100644 index 000000000..ebe889a88 --- /dev/null +++ b/apps/website/app/leagues/[id]/standings/page.tsx @@ -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(null); + const [standings, setStandings] = useState([]); + const [drivers, setDrivers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
Loading standings...
+
+
+ ); + } + + if (error || !league) { + return ( +
+
+ +
+ {error || 'League not found'} +
+ +
+
+
+ ); + } + + return ( +
+
+ {/* Breadcrumb */} +
+ +
+ + {/* Page Header */} +
+

Championship Standings

+

{league.name}

+
+ + {/* Standings Content */} + + {standings.length > 0 ? ( + <> +

Current Standings

+ + + ) : ( +
+
No standings available yet
+

+ Standings will appear after race results are imported +

+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/leagues/page.tsx b/apps/website/app/leagues/page.tsx new file mode 100644 index 000000000..849340658 --- /dev/null +++ b/apps/website/app/leagues/page.tsx @@ -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([]); + 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 ( +
+
Loading leagues...
+
+ ); + } + + return ( +
+
+
+

Leagues

+

+ {leagues.length === 0 + ? 'Create your first league to get started' + : `${leagues.length} ${leagues.length === 1 ? 'league' : 'leagues'} available`} +

+
+ + +
+ + {showCreateForm && ( + +
+

Create New League

+

+ Experiment with different point systems +

+
+ +
+ )} + + {leagues.length === 0 ? ( + +
+ +

No leagues yet

+

+ Create one to get started. Alpha data resets on page reload. +

+ +
+
+ ) : ( +
+ {leagues.map((league) => ( + handleLeagueClick(league.id)} + /> + ))} +
+ )} +
+ ); + } \ No newline at end of file diff --git a/apps/website/app/page.tsx b/apps/website/app/page.tsx index 41ebaf967..f13e9d434 100644 --- a/apps/website/app/page.tsx +++ b/apps/website/app/page.tsx @@ -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,10 +17,270 @@ 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 ( - -
+
+ {/* Welcome Header */} +
+

GridPilot Alpha

+

+ Complete workflow prototype. Test freely — all data is temporary. +

+
+ + {/* Companion Status */} +
+ +
+ + {/* What's in Alpha */} + +

What's in Alpha

+
+
+ + + + Driver profile creation +
+
+ + + + League management +
+
+ + + + Race scheduling +
+
+ + + + CSV result import +
+
+ + + + Championship standings +
+
+ + + + Full workflow end-to-end +
+
+
+ + {/* What's Coming */} + +

What's Coming

+
+
+ + + + Persistent data storage +
+
+ + + + Automated session creation +
+
+ + + + Automated result import +
+
+ + + + Multi-league memberships +
+
+ + + + Team championships +
+
+ + + + Advanced statistics +
+
+ + + + Social features +
+
+ + + + League discovery +
+
+
+ + {/* Known Limitations */} + +
+
+ + + +
+
+

Known Limitations

+
    +
  • + + Data resets on page reload (in-memory only) +
  • +
  • + + Manual iRacing session creation required +
  • +
  • + + Manual CSV result upload required +
  • +
  • + + Single league membership per driver +
  • +
  • + + No user authentication +
  • +
  • + + iRacing platform only +
  • +
+
+
+
+ + {/* Quick Start Guide */} + +

Quick Start Guide

+
+
+ + 1 + +
+

Create Your Profile

+

Set up your driver profile with racing number and iRacing ID.

+ +
+
+ +
+ + 2 + +
+

Join or Create a League

+

Browse available leagues or create your own.

+ +
+
+ +
+ + 3 + +
+

Schedule Races

+

Create race events and manage your schedule.

+ +
+
+
+
+ + {/* Navigation Cards */} +
+
router.push('/profile')} className="cursor-pointer"> + +
+
+ + + +
+

Profile

+

Manage your driver profile

+
+
+
+ +
router.push('/leagues')} className="cursor-pointer"> + +
+
+ + + +
+

Leagues

+

Browse and join leagues

+
+
+
+ +
router.push('/races')} className="cursor-pointer"> + +
+
+ + + +
+

Races

+

View race schedule

+
+
+
+
+
+ ); +} + +function LandingPage() { + return ( +
{/* Section 1: A Persistent Identity */} @@ -206,10 +472,19 @@ export default function HomePage() { layout="text-right" /> - - -
-
- + + +
+
); +} + +export default function HomePage() { + const mode = getAppMode(); + + if (mode === 'alpha') { + return ; + } + + return ; } \ No newline at end of file diff --git a/apps/website/app/profile/page.tsx b/apps/website/app/profile/page.tsx new file mode 100644 index 000000000..ca287a454 --- /dev/null +++ b/apps/website/app/profile/page.tsx @@ -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 ( +
+
+

Driver Profile

+

+ {driver ? 'Your GridPilot profile' : 'Create your GridPilot profile to get started'} +

+
+ + {driver ? ( + <> + +
+ +
+
+ + ) : ( + +
+

Create Your Profile

+

+ Create your driver profile. Alpha data resets on reload, so test freely. +

+
+ +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx new file mode 100644 index 000000000..6c8230849 --- /dev/null +++ b/apps/website/app/races/[id]/page.tsx @@ -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(null); + const [league, setLeague] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
Loading race details...
+
+
+ ); + } + + if (error || !race) { + return ( +
+
+ +
+ {error || 'Race not found'} +
+ +
+
+
+ ); + } + + return ( +
+
+ {/* Breadcrumb */} +
+ +
+ + {/* Companion Status */} + +
+ +
+
+ + {/* Race Header */} +
+
+
+

{race.track}

+ {league && ( +

{league.name}

+ )} +
+ + {race.status.charAt(0).toUpperCase() + race.status.slice(1)} + +
+
+ + {/* Companion Instructions for Scheduled Races */} + {race.status === 'scheduled' && ( +
+ +
+ )} + +
+ {/* Race Details */} + +

Race Details

+ +
+ {/* Date & Time */} +
+ +

+ {formatDateTime(race.scheduledAt)} +

+
+ {formatDate(race.scheduledAt)} + {formatTime(race.scheduledAt)} +
+
+ + {/* Track */} +
+ +

{race.track}

+
+ + {/* Car */} +
+ +

{race.car}

+
+ + {/* Session Type */} +
+ +

{race.sessionType}

+
+ + {/* League */} +
+ + {league ? ( + + ) : ( +

ID: {race.leagueId.slice(0, 8)}...

+ )} +
+
+
+ + {/* Actions */} + +

Actions

+ +
+ {race.status === 'completed' && ( + + )} + + {race.status === 'scheduled' && ( + + )} + + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/races/[id]/results/page.tsx b/apps/website/app/races/[id]/results/page.tsx new file mode 100644 index 000000000..8590db283 --- /dev/null +++ b/apps/website/app/races/[id]/results/page.tsx @@ -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(null); + const [league, setLeague] = useState(null); + const [results, setResults] = useState([]); + const [drivers, setDrivers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 => { + if (!league) return {}; + + const pointsSystems: Record> = { + '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 ( +
+
+
Loading results...
+
+
+ ); + } + + if (error && !race) { + return ( +
+
+ +
+ {error || 'Race not found'} +
+ +
+
+
+ ); + } + + const hasResults = results.length > 0; + + return ( +
+
+ {/* Breadcrumb */} +
+ +
+ + {/* Page Header */} +
+

Race Results

+ {race && ( +
+

{race.track}

+ {league && ( +

{league.name}

+ )} +
+ )} +
+ + {/* Success Message */} + {importSuccess && ( +
+ Success! Results imported and standings updated. +
+ )} + + {/* Error Message */} + {error && ( +
+ Error: {error} +
+ )} + + {/* Content */} + + {hasResults ? ( + <> +

Results

+ + + ) : ( + <> +

Import Results

+

+ No results imported. Upload CSV to test the standings system. +

+ {importing ? ( +
+ Importing results and updating standings... +
+ ) : ( + + )} + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/races/page.tsx b/apps/website/app/races/page.tsx new file mode 100644 index 000000000..e1ac281ad --- /dev/null +++ b/apps/website/app/races/page.tsx @@ -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([]); + const [leagues, setLeagues] = useState>(new Map()); + const [loading, setLoading] = useState(true); + const [showScheduleForm, setShowScheduleForm] = useState(false); + + // Filters + const [statusFilter, setStatusFilter] = useState('all'); + const [leagueFilter, setLeagueFilter] = useState('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(); + 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 ( +
+
+
Loading races...
+
+
+ ); + } + + if (showScheduleForm) { + return ( +
+
+
+ +
+ + +

Schedule New Race

+ { + router.push(`/races/${race.id}`); + }} + onCancel={() => setShowScheduleForm(false)} + /> +
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+

Races

+ +
+

+ Manage and view all scheduled races across your leagues +

+
+ + {/* Filters */} + +
+ {/* Time Filter */} +
+ + +
+ + {/* Status Filter */} +
+ + +
+ + {/* League Filter */} +
+ + +
+
+
+ + {/* Race List */} + {filteredRaces.length === 0 ? ( + +
+ {races.length === 0 ? ( + <> +

No races scheduled

+

Try the full workflow in alpha mode

+ + ) : ( + <> +

No races match your filters

+

Try adjusting your filter criteria

+ + )} +
+
+ ) : ( +
+ {filteredRaces.map(race => ( + router.push(`/races/${race.id}`)} + /> + ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/application/mappers/EntityMappers.ts b/apps/website/application/mappers/EntityMappers.ts new file mode 100644 index 000000000..5e3d9043c --- /dev/null +++ b/apps/website/application/mappers/EntityMappers.ts @@ -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; + }; + 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, + })); + } +} \ No newline at end of file diff --git a/apps/website/application/ports/IDriverRepository.ts b/apps/website/application/ports/IDriverRepository.ts new file mode 100644 index 000000000..2e068411a --- /dev/null +++ b/apps/website/application/ports/IDriverRepository.ts @@ -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; + + /** + * Find a driver by iRacing ID + */ + findByIRacingId(iracingId: string): Promise; + + /** + * Find all drivers + */ + findAll(): Promise; + + /** + * Create a new driver + */ + create(driver: Driver): Promise; + + /** + * Update an existing driver + */ + update(driver: Driver): Promise; + + /** + * Delete a driver by ID + */ + delete(id: string): Promise; + + /** + * Check if a driver exists by ID + */ + exists(id: string): Promise; + + /** + * Check if an iRacing ID is already registered + */ + existsByIRacingId(iracingId: string): Promise; +} \ No newline at end of file diff --git a/apps/website/application/ports/ILeagueRepository.ts b/apps/website/application/ports/ILeagueRepository.ts new file mode 100644 index 000000000..301de7146 --- /dev/null +++ b/apps/website/application/ports/ILeagueRepository.ts @@ -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; + + /** + * Find all leagues + */ + findAll(): Promise; + + /** + * Find leagues by owner ID + */ + findByOwnerId(ownerId: string): Promise; + + /** + * Create a new league + */ + create(league: League): Promise; + + /** + * Update an existing league + */ + update(league: League): Promise; + + /** + * Delete a league by ID + */ + delete(id: string): Promise; + + /** + * Check if a league exists by ID + */ + exists(id: string): Promise; + + /** + * Search leagues by name + */ + searchByName(query: string): Promise; +} \ No newline at end of file diff --git a/apps/website/application/ports/IRaceRepository.ts b/apps/website/application/ports/IRaceRepository.ts new file mode 100644 index 000000000..675eb06ca --- /dev/null +++ b/apps/website/application/ports/IRaceRepository.ts @@ -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; + + /** + * Find all races + */ + findAll(): Promise; + + /** + * Find races by league ID + */ + findByLeagueId(leagueId: string): Promise; + + /** + * Find upcoming races for a league + */ + findUpcomingByLeagueId(leagueId: string): Promise; + + /** + * Find completed races for a league + */ + findCompletedByLeagueId(leagueId: string): Promise; + + /** + * Find races by status + */ + findByStatus(status: RaceStatus): Promise; + + /** + * Find races scheduled within a date range + */ + findByDateRange(startDate: Date, endDate: Date): Promise; + + /** + * Create a new race + */ + create(race: Race): Promise; + + /** + * Update an existing race + */ + update(race: Race): Promise; + + /** + * Delete a race by ID + */ + delete(id: string): Promise; + + /** + * Check if a race exists by ID + */ + exists(id: string): Promise; +} \ No newline at end of file diff --git a/apps/website/application/ports/IResultRepository.ts b/apps/website/application/ports/IResultRepository.ts new file mode 100644 index 000000000..b282df632 --- /dev/null +++ b/apps/website/application/ports/IResultRepository.ts @@ -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; + + /** + * Find all results + */ + findAll(): Promise; + + /** + * Find results by race ID + */ + findByRaceId(raceId: string): Promise; + + /** + * Find results by driver ID + */ + findByDriverId(driverId: string): Promise; + + /** + * Find results by driver ID for a specific league + */ + findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise; + + /** + * Create a new result + */ + create(result: Result): Promise; + + /** + * Create multiple results + */ + createMany(results: Result[]): Promise; + + /** + * Update an existing result + */ + update(result: Result): Promise; + + /** + * Delete a result by ID + */ + delete(id: string): Promise; + + /** + * Delete all results for a race + */ + deleteByRaceId(raceId: string): Promise; + + /** + * Check if a result exists by ID + */ + exists(id: string): Promise; + + /** + * Check if results exist for a race + */ + existsByRaceId(raceId: string): Promise; +} \ No newline at end of file diff --git a/apps/website/application/ports/IStandingRepository.ts b/apps/website/application/ports/IStandingRepository.ts new file mode 100644 index 000000000..7f45dfa4a --- /dev/null +++ b/apps/website/application/ports/IStandingRepository.ts @@ -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; + + /** + * Find standing for a specific driver in a league + */ + findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise; + + /** + * Find all standings + */ + findAll(): Promise; + + /** + * Create or update a standing + */ + save(standing: Standing): Promise; + + /** + * Create or update multiple standings + */ + saveMany(standings: Standing[]): Promise; + + /** + * Delete a standing + */ + delete(leagueId: string, driverId: string): Promise; + + /** + * Delete all standings for a league + */ + deleteByLeagueId(leagueId: string): Promise; + + /** + * Check if a standing exists + */ + exists(leagueId: string, driverId: string): Promise; + + /** + * Recalculate standings for a league based on race results + */ + recalculate(leagueId: string): Promise; +} \ No newline at end of file diff --git a/apps/website/components/alpha/AlphaBanner.tsx b/apps/website/components/alpha/AlphaBanner.tsx new file mode 100644 index 000000000..6204b7713 --- /dev/null +++ b/apps/website/components/alpha/AlphaBanner.tsx @@ -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 ( +
+
+
+
+ + + +

+ Alpha Version — Data resets on page reload. No persistent storage. +

+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/alpha/AlphaFooter.tsx b/apps/website/components/alpha/AlphaFooter.tsx new file mode 100644 index 000000000..5da4ed269 --- /dev/null +++ b/apps/website/components/alpha/AlphaFooter.tsx @@ -0,0 +1,41 @@ +'use client'; + +export default function AlphaFooter() { + return ( + + ); +} \ No newline at end of file diff --git a/apps/website/components/alpha/AlphaNav.tsx b/apps/website/components/alpha/AlphaNav.tsx new file mode 100644 index 000000000..c36ebbce8 --- /dev/null +++ b/apps/website/components/alpha/AlphaNav.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/apps/website/components/alpha/CompanionInstructions.tsx b/apps/website/components/alpha/CompanionInstructions.tsx new file mode 100644 index 000000000..011994125 --- /dev/null +++ b/apps/website/components/alpha/CompanionInstructions.tsx @@ -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 ( + +
+
+ + + +
+
+

Alpha Manual Workflow

+

+ Companion automation coming in production. For alpha, races are created manually. +

+
+
+ +
+
+ + 1 + +

+ Schedule race in GridPilot (completed) +

+
+ +
+ + 2 + +

+ Copy race details using button below +

+
+ +
+ + 3 + +

+ Create hosted session manually in iRacing website +

+
+ +
+ + 4 + +

+ Return to GridPilot after race completes +

+
+ +
+ + 5 + +

+ Import results via CSV upload +

+
+
+ +
+
+
+{raceDetails}
+          
+
+ + +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/alpha/CompanionStatus.tsx b/apps/website/components/alpha/CompanionStatus.tsx new file mode 100644 index 000000000..c58f3fe5c --- /dev/null +++ b/apps/website/components/alpha/CompanionStatus.tsx @@ -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 ( +
+
+
+ + Companion App: + {isConnected ? 'Connected' : 'Disconnected'} + + +
+ + {statusMessage} + +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/alpha/CreateDriverForm.tsx b/apps/website/components/alpha/CreateDriverForm.tsx new file mode 100644 index 000000000..6bdcb3363 --- /dev/null +++ b/apps/website/components/alpha/CreateDriverForm.tsx @@ -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({}); + + const [formData, setFormData] = useState({ + name: '', + iracingId: '', + country: '', + bio: '' + }); + + const validateForm = async (): Promise => { + 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 ( + <> + +
+
+ + setFormData({ ...formData, name: e.target.value })} + error={!!errors.name} + errorMessage={errors.name} + placeholder="Max Verstappen" + disabled={loading} + /> +
+ +
+ + setFormData({ ...formData, iracingId: e.target.value })} + error={!!errors.iracingId} + errorMessage={errors.iracingId} + placeholder="123456" + disabled={loading} + /> +
+ +
+ + setFormData({ ...formData, country: e.target.value })} + error={!!errors.country} + errorMessage={errors.country} + placeholder="NL" + maxLength={3} + disabled={loading} + /> +

Use ISO 3166-1 alpha-2 or alpha-3 code

+
+ +
+ +