diff --git a/docs/ALPHA_PLAN.md b/docs/ALPHA_PLAN.md deleted file mode 100644 index c2007f71a..000000000 --- a/docs/ALPHA_PLAN.md +++ /dev/null @@ -1,1063 +0,0 @@ -# GridPilot Alpha Development Plan - -## Overview - -**Mission:** Prove automation + unified management value proposition through functional alpha. - -**Scope:** In-memory web app demonstrating core league management workflows integrated with companion automation. - -**Timeline:** Alpha validates technical approach; production follows with persistent infrastructure. - -**Value Proof:** -- Automation reduces admin workload (companion integration) -- Unified platform consolidates fragmented tools (web app) -- Clean Architecture enables rapid iteration (in-memory → production swap) - ---- - -## Technical Approach - -### Architecture Strategy - -**Production domain logic, swappable infrastructure:** - -``` -Domain (Production) ← Business rules, invariants -Application (Production) ← Use cases, orchestration -Infrastructure (Swappable) ← In-memory adapters for alpha -Presentation (Production) ← Next.js UI components -``` - -**Alpha = Real code + Fake persistence** - -- Domain entities enforce actual business rules -- Application use cases implement real workflows -- Infrastructure uses in-memory adapters (no database) -- Presentation renders production-ready UI - -**Post-Alpha Migration:** - -```typescript -// Alpha -const driverRepo = new InMemoryDriverRepository(); - -// Production -const driverRepo = new PostgresDriverRepository(pool); -``` - -Same ports, same use cases, different adapters. - ---- - -### Mode System Design - -**Current:** `pre-launch` | `post-launch` -**Alpha:** `pre-launch` | `alpha` - -**Mode Behavior:** - -- `pre-launch`: Landing page only (current state) -- `alpha`: Full app UI with in-memory data (no auth, no persistence) - -**Implementation:** - -```typescript -// lib/mode.ts -export type AppMode = 'pre-launch' | 'alpha'; - -// Middleware gates -if (mode === 'alpha') { - // Enable /dashboard, /leagues, /profile routes - // Inject in-memory repositories -} -``` - -**Environment Control:** - -``` -GRIDPILOT_MODE=alpha npm run dev -``` - ---- - -## Features - -### F1: Driver Profile Creation - -**User Story:** Driver creates single profile to participate in leagues. - -**Acceptance Criteria:** -- Driver enters: name, racing number, iRacing ID -- System validates: unique number per league, valid ID format -- Profile displays: basic info, empty race history -- Single profile per user (alpha constraint) - -**Domain Model:** - -```typescript -class Driver { - id: DriverId - name: string - racingNumber: number - iRacingId: string - createdAt: Date -} -``` - -**UI Flow:** -1. `/profile/create` form -2. Validation feedback (inline) -3. Success → redirect to `/dashboard` -4. Profile visible at `/profile` - ---- - -### F2: League Management - -**User Story:** Admin creates league, drivers join, schedule displays. - -**Acceptance Criteria:** -- Admin creates league: name, description, rules -- System generates: league ID, creation timestamp -- Drivers browse available leagues -- Drivers join single league (alpha constraint) -- League page shows: roster, schedule placeholder, standings - -**Domain Model:** - -```typescript -class League { - id: LeagueId - name: string - adminId: DriverId - description: string - rules: string - members: DriverId[] - createdAt: Date -} -``` - -**UI Flow:** -1. `/leagues/create` (admin only) -2. `/leagues` (browse/join) -3. `/leagues/:id` (league detail) -4. Join button → updates roster -5. Leave button available - ---- - -### F3: Race Scheduling via Companion - -**User Story:** Admin schedules race through companion, web app displays schedule. - -**Acceptance Criteria:** -- Admin configures race in companion app -- Companion sends race metadata to web app (IPC/API stub) -- Web app displays: track, cars, date/time, session settings -- Schedule shows upcoming races -- Past races marked as completed - -**Domain Model:** - -```typescript -class Race { - id: RaceId - leagueId: LeagueId - trackId: string - carIds: string[] - scheduledAt: Date - sessionName: string - status: 'scheduled' | 'completed' | 'cancelled' -} -``` - -**Integration:** - -```typescript -// Companion → Web App (simulated in alpha) -interface RaceScheduleRequest { - leagueId: string - sessionConfig: HostedSessionConfig - scheduledAt: Date -} -``` - -**UI Flow:** -1. `/leagues/:id/schedule` displays races -2. Each race shows: date, track, cars -3. Status badge: upcoming/completed -4. Click race → detail view - ---- - -### F4: Results Display (Manual Import) - -**User Story:** Admin imports results CSV, system displays classification and updates standings. - -**Acceptance Criteria:** -- Admin uploads iRacing results CSV -- System parses: positions, drivers, incidents, lap times -- Classification displays immediately -- Standings auto-calculate -- Results linked to scheduled race - -**Domain Model:** - -```typescript -class RaceResult { - id: ResultId - raceId: RaceId - driverId: DriverId - position: number - incidents: number - fastestLap?: number - status: 'finished' | 'dnf' | 'dsq' -} -``` - -**UI Flow:** -1. `/leagues/:id/races/:raceId/results/import` -2. CSV upload field -3. Preview parsed results -4. Confirm → save -5. Redirect to `/leagues/:id/races/:raceId/results` - ---- - -### F5: Standings Calculation - -**User Story:** System calculates driver and team standings from race results. - -**Acceptance Criteria:** -- Points awarded per position (configurable preset) -- Standings update after each result import -- Display: position, driver, points, races -- Team standings aggregate member points -- Drop weeks not implemented (alpha limitation) - -**Domain Model:** - -```typescript -class Standing { - leagueId: LeagueId - driverId: DriverId - position: number - points: number - racesCompleted: number -} - -class PointsPreset { - name: string - pointsMap: Map // position → points -} -``` - -**Calculation:** - -```typescript -class CalculateStandingsUseCase { - execute(leagueId: LeagueId): Standing[] { - const results = resultRepo.findByLeague(leagueId); - const points = applyPointsPreset(results); - return sortByPoints(points); - } -} -``` - -**UI Flow:** -1. `/leagues/:id/standings` (driver view) -2. Table: pos, driver, points, races -3. Auto-updates after result import -4. Team standings at `/leagues/:id/standings/teams` - ---- - -## Data Model (In-Memory) - -### Core Entities - -```typescript -// Domain/entities/Driver.ts -class Driver { - id: DriverId - name: string - racingNumber: number - iRacingId: string - currentLeagueId?: LeagueId - createdAt: Date -} - -// Domain/entities/League.ts -class League { - id: LeagueId - name: string - adminId: DriverId - description: string - rules: string - memberIds: DriverId[] - pointsPreset: PointsPreset - createdAt: Date -} - -// Domain/entities/Race.ts -class Race { - id: RaceId - leagueId: LeagueId - trackId: string - carIds: string[] - scheduledAt: Date - sessionName: string - status: RaceStatus - hostedSessionId?: string // Links to companion -} - -// Domain/entities/RaceResult.ts -class RaceResult { - id: ResultId - raceId: RaceId - driverId: DriverId - position: number - incidents: number - fastestLapTime?: number - status: ResultStatus -} - -// Domain/entities/Standing.ts -class Standing { - leagueId: LeagueId - driverId: DriverId - position: number - points: number - racesCompleted: number - updatedAt: Date -} -``` - -### Value Objects - -```typescript -// Domain/value-objects/DriverId.ts -class DriverId { - value: string - static generate(): DriverId -} - -// Domain/value-objects/RaceStatus.ts -type RaceStatus = 'scheduled' | 'completed' | 'cancelled'; - -// Domain/value-objects/ResultStatus.ts -type ResultStatus = 'finished' | 'dnf' | 'dsq'; - -// Domain/value-objects/PointsPreset.ts -class PointsPreset { - name: string - pointsMap: Map - - static F1_2024 = new PointsPreset('F1 2024', new Map([ - [1, 25], [2, 18], [3, 15], [4, 12], [5, 10], - [6, 8], [7, 6], [8, 4], [9, 2], [10, 1] - ])); -} -``` - ---- - -## UI/UX Flow - -### User Journey: Driver Registration → Race → Results - -**Step 1: Landing (Pre-Launch Mode)** -- Visit `/` -- See landing page -- Email signup (not functional in alpha) - -**Step 2: Switch to Alpha Mode** -- `GRIDPILOT_MODE=alpha` -- Reload page -- Landing replaced with dashboard redirect - -**Step 3: Create Profile** -- Visit `/profile/create` -- Enter: "John Doe", number 42, iRacing ID "123456" -- Submit → profile created -- Redirect `/dashboard` - -**Step 4: Join League** -- Visit `/leagues` -- See available leagues -- Click "European GT League" -- Review schedule, rules -- Click "Join League" -- Confirmation modal -- Success → league roster updated - -**Step 5: View Schedule** -- Dashboard shows next race -- Visit `/leagues/:id/schedule` -- See upcoming: "Monza GP - March 15, 2025" -- Race details: GT3, 60min, 19:00 CET - -**Step 6: Race Day (External)** -- Driver joins iRacing hosted session -- Companion automation created session -- Driver races (outside GridPilot) - -**Step 7: Results Import (Admin)** -- Admin downloads iRacing CSV -- Visit `/leagues/:id/races/:raceId/results/import` -- Upload CSV -- Preview shows classification -- Confirm import - -**Step 8: View Results & Standings** -- Driver visits `/leagues/:id/races/:raceId/results` -- Sees: 3rd place, 2x incidents, 1:42.5 fastest lap -- Click "Standings" -- Driver standings show: 15 points, 1 race -- Team standings aggregate teammates - ---- - -## Companion Integration - -### Race Scheduling Flow - -**Companion Side:** - -```typescript -// Companion creates hosted session -const sessionConfig: HostedSessionConfig = { - sessionName: 'European GT - Monza GP', - trackId: 'monza-gp', - carIds: ['porsche-911-gt3-r'], - scheduledAt: new Date('2025-03-15T19:00:00Z'), - // ... full config -}; - -// Send to web app (alpha: IPC stub) -await webAppClient.scheduleRace({ - leagueId: 'league-123', - sessionConfig, -}); -``` - -**Web App Side:** - -```typescript -// Application/use-cases/ScheduleRaceUseCase.ts -class ScheduleRaceUseCase { - async execute(req: ScheduleRaceRequest): Promise { - // Validate league exists - const league = await leagueRepo.findById(req.leagueId); - - // Create race entity - const race = Race.create({ - leagueId: req.leagueId, - trackId: req.sessionConfig.trackId, - carIds: req.sessionConfig.carIds, - scheduledAt: req.scheduledAt, - sessionName: req.sessionConfig.sessionName, - status: 'scheduled', - }); - - // Persist (in-memory for alpha) - await raceRepo.save(race); - - return race; - } -} -``` - -**Alpha Implementation:** - -- Companion → Web App via simulated IPC -- Web app stores race in `InMemoryRaceRepository` -- Schedule view renders from repository -- No actual Playwright automation in web app - -**Post-Alpha:** - -- Companion → Web App via REST API -- Web app stores in PostgreSQL -- Companion polls for scheduled races -- Full automation execution - ---- - -### Result Import Flow - -**Manual Import (Alpha):** - -```typescript -// Application/use-cases/ImportRaceResultsUseCase.ts -class ImportRaceResultsUseCase { - async execute(raceId: RaceId, csvData: string): Promise { - // Parse CSV - const parsed = parseIRacingCSV(csvData); - - // Validate race exists and is completed - const race = await raceRepo.findById(raceId); - - // Map to domain entities - const results = parsed.map(row => RaceResult.create({ - raceId, - driverId: findDriverByIRacingId(row.customerId), - position: row.finishPos, - incidents: row.incidents, - fastestLapTime: row.bestLapTime, - status: row.reasonOut ? 'dnf' : 'finished', - })); - - // Persist - await resultRepo.saveMany(results); - - // Trigger standings recalculation - await calculateStandingsUseCase.execute(race.leagueId); - - return results; - } -} -``` - -**Post-Alpha Automation:** - -- Web app calls iRacing API directly -- Automated result fetch after session end -- No CSV upload required - ---- - -## Testing Strategy - -### Unit Tests - -**Domain Layer:** - -```typescript -describe('Driver', () => { - it('validates racing number uniqueness per league', () => { - // Test domain invariants - }); -}); - -describe('Standing', () => { - it('calculates points from results', () => { - // Test scoring logic - }); -}); - -describe('PointsPreset', () => { - it('applies F1 2024 points correctly', () => { - // Test point mapping - }); -}); -``` - -**Application Layer:** - -```typescript -describe('CreateDriverProfileUseCase', () => { - it('creates driver with valid data', async () => { - const repo = new InMemoryDriverRepository(); - const useCase = new CreateDriverProfileUseCase(repo); - - const driver = await useCase.execute({ - name: 'John Doe', - racingNumber: 42, - iRacingId: '123456', - }); - - expect(driver.name).toBe('John Doe'); - }); - - it('rejects duplicate racing number in same league', async () => { - // Test business rule enforcement - }); -}); -``` - -### Integration Tests - -**Repository Layer:** - -```typescript -describe('InMemoryDriverRepository', () => { - it('persists and retrieves driver', async () => { - const repo = new InMemoryDriverRepository(); - const driver = Driver.create({...}); - - await repo.save(driver); - const found = await repo.findById(driver.id); - - expect(found).toEqual(driver); - }); - - it('handles concurrent writes', async () => { - // Test in-memory consistency - }); -}); -``` - -**Use Case Workflows:** - -```typescript -describe('Race Result Import Workflow', () => { - it('imports CSV and updates standings', async () => { - // Setup: league, drivers, scheduled race - // Execute: import CSV - // Assert: results saved, standings updated - }); -}); -``` - -### E2E Tests (Playwright) - -**User Flows:** - -```typescript -test('driver can create profile and join league', async ({ page }) => { - await page.goto('/profile/create'); - await page.fill('[name="name"]', 'John Doe'); - await page.fill('[name="racingNumber"]', '42'); - await page.fill('[name="iRacingId"]', '123456'); - await page.click('button[type="submit"]'); - - await expect(page).toHaveURL('/dashboard'); - - await page.goto('/leagues'); - await page.click('text=European GT League'); - await page.click('text=Join League'); - - await expect(page.locator('.roster')).toContainText('John Doe'); -}); -``` - -**Integration with Companion:** - -```typescript -test('admin schedules race via companion integration', async ({ page }) => { - // Simulate companion IPC call - await simulateCompanionScheduleRace({ - leagueId: 'league-123', - sessionConfig: { /* ... */ }, - }); - - // Verify web app displays scheduled race - await page.goto('/leagues/league-123/schedule'); - await expect(page.locator('.race-card')).toContainText('Monza GP'); -}); -``` - ---- - -## Success Metrics - -### Technical Validation - -**Architecture Proof:** -- [ ] All use cases testable in isolation -- [ ] Domain logic has zero infrastructure dependencies -- [ ] In-memory adapters swap cleanly with production stubs -- [ ] No business logic in presentation layer - -**Feature Completeness:** -- [ ] Driver can create profile -- [ ] Driver can join single league -- [ ] Admin can create league -- [ ] Schedule displays companion-scheduled races -- [ ] Results import updates standings -- [ ] Standings calculate correctly - -### User Experience Validation - -**Admin Workflow:** -- [ ] Admin creates league in <2 minutes -- [ ] Admin imports results in <30 seconds -- [ ] Admin views standings with zero manual calculation - -**Driver Workflow:** -- [ ] Driver creates profile in <1 minute -- [ ] Driver finds and joins league in <2 minutes -- [ ] Driver views schedule without asking admin -- [ ] Driver sees results immediately after import - -### Value Proposition Validation - -**Automation Integration:** -- [ ] Companion schedules race → web app displays schedule (proves connectivity) -- [ ] Web app schedule guides companion automation (proves unified workflow) - -**Unified Management:** -- [ ] Single source of truth for league data -- [ ] No Discord/spreadsheet required for standings -- [ ] Driver profile persists across sessions (in-memory during session) - ---- - -## Implementation Phases - -### Phase 1: Foundation (Week 1-2) - -**Deliverables:** -- [ ] Mode system: `pre-launch` → `alpha` transition -- [ ] Middleware: mode-gated routes -- [ ] Domain models: Driver, League, Race, RaceResult, Standing -- [ ] In-memory repositories: all entities -- [ ] Basic UI layout: dashboard, navigation - -**Dependencies:** -- Clean Architecture patterns from companion -- Next.js 15 app router -- TypeScript strict mode - ---- - -### Phase 2: Driver & League Core (Week 3-4) - -**Deliverables:** -- [ ] Use case: CreateDriverProfile -- [ ] Use case: CreateLeague -- [ ] Use case: JoinLeague -- [ ] UI: `/profile/create` -- [ ] UI: `/leagues` (browse) -- [ ] UI: `/leagues/create` -- [ ] UI: `/leagues/:id` (detail) -- [ ] Unit tests: domain validation -- [ ] Integration tests: repository operations - -**Dependencies:** -- Phase 1 complete -- Driver/League value objects - ---- - -### Phase 3: Scheduling & Companion Integration (Week 5-6) - -**Deliverables:** -- [ ] Use case: ScheduleRace (from companion) -- [ ] IPC stub: companion → web app communication -- [ ] Domain model: Race status transitions -- [ ] UI: `/leagues/:id/schedule` -- [ ] UI: Race detail cards -- [ ] Integration tests: companion scheduling flow - -**Dependencies:** -- Phase 2 complete -- Companion POC running -- HostedSessionConfig understanding - ---- - -### Phase 4: Results & Standings (Week 7-8) - -**Deliverables:** -- [ ] Use case: ImportRaceResults -- [ ] Use case: CalculateStandings -- [ ] CSV parser: iRacing results format -- [ ] PointsPreset: F1 2024 preset -- [ ] UI: `/leagues/:id/races/:raceId/results/import` -- [ ] UI: `/leagues/:id/races/:raceId/results` -- [ ] UI: `/leagues/:id/standings` -- [ ] Unit tests: points calculation -- [ ] Integration tests: results → standings pipeline - -**Dependencies:** -- Phase 3 complete -- iRacing CSV samples - ---- - -### Phase 5: Polish & Testing (Week 9-10) - -**Deliverables:** -- [ ] E2E tests: full user flows -- [ ] Error handling: all use cases -- [ ] Loading states: all async operations -- [ ] Mobile responsive: all views -- [ ] Theme compliance: THEME.md -- [ ] Voice compliance: VOICE.md -- [ ] Documentation: alpha usage guide - -**Dependencies:** -- Phase 4 complete -- Design system from landing page - ---- - -## Known Limitations (Alpha Scope) - -### Explicitly Deferred Features - -**Authentication & Authorization:** -- No login/signup -- No user sessions -- No permission checks -- **Rationale:** In-memory data resets on restart; auth irrelevant for testing workflows - -**Data Persistence:** -- No database -- No file storage -- Data clears on server restart -- **Rationale:** Alpha validates domain logic and UI flows, not persistence - -**Multi-League Support:** -- Driver joins single league only -- No cross-league profile -- No multi-league standings -- **Rationale:** Simplifies alpha scope; multi-league in production - -**Advanced Scoring:** -- No drop weeks -- No bonus points (fastest lap, etc.) -- Single points preset only -- **Rationale:** Complex scoring deferred to production - -**Team Competition:** -- No team management -- No team standings -- No team profiles -- **Rationale:** Driver/admin flows first; teams in production - -**Result Automation:** -- Manual CSV import only -- No iRacing API integration -- No live result fetch -- **Rationale:** Proves import pipeline; automation post-alpha - -**Complaint & Penalty System:** -- Not implemented in alpha -- **Rationale:** Core competition flows first; penalties in production - -**Multi-Sim Support:** -- iRacing only -- **Rationale:** Alpha validates single-sim workflows - -**Rating System:** -- Not implemented in alpha -- **Rationale:** Requires production data volume - ---- - -## Alpha Success Definition - -**Alpha is successful if:** - -1. **Technical:** In-memory adapters swap cleanly with production stubs (no domain/app changes) -2. **Functional:** All 5 core features work end-to-end -3. **Integration:** Companion schedules race, web app displays it -4. **Value:** Admin workload reduced by automation visibility -5. **Architecture:** Clean Architecture boundaries respected (testable via unit tests) - -**Alpha is ready for production when:** - -1. Use cases remain unchanged -2. Only infrastructure layer needs replacement -3. UI components are production-ready -4. Theme and voice guidelines followed -5. E2E tests pass consistently - ---- - -## Post-Alpha Migration Path - -### Infrastructure Swap - -**Repositories:** - -```typescript -// Alpha -const driverRepo = new InMemoryDriverRepository(); - -// Production -const driverRepo = new PostgresDriverRepository(pool); -``` - -**Authentication:** - -```typescript -// Alpha: No-op adapter -class NoAuthService implements IAuthService { - async getCurrentUser() { return mockDriver; } -} - -// Production -class NextAuthService implements IAuthService { - async getCurrentUser() { return getServerSession(); } -} -``` - -**API Integration:** - -```typescript -// Alpha: IPC stub -class StubCompanionClient implements ICompanionClient { - async scheduleRace() { /* in-memory */ } -} - -// Production -class HTTPCompanionClient implements ICompanionClient { - async scheduleRace() { return fetch('/api/races'); } -} -``` - -### Zero Domain Changes - -- `Driver`, `League`, `Race` entities unchanged -- `CreateDriverProfileUseCase` unchanged -- `CalculateStandingsUseCase` unchanged -- UI components unchanged - -**Only wiring changes:** - -```typescript -// apps/website/app/providers.tsx -const mode = getAppMode(); - -const repositories = mode === 'alpha' - ? createInMemoryRepositories() - : createProductionRepositories(db); -``` - ---- - -## Theme & Voice Compliance - -### Visual Guidelines (THEME.md) - -**Colors:** -- Deep Graphite `#0E0F11` (background) -- Primary Blue `#198CFF` (accents) -- Performance Green `#6FE37A` (success states) - -**Typography:** -- Inter font family -- Semibold headings -- Light/regular body - -**Animations:** -- 150-250ms transitions -- Subtle hover states -- Smooth scrolling - -**Components:** -- 6-8px rounded cards -- Soft shadows (blur 20-28px) -- Hover glow on interactive elements - -### Voice Guidelines (VOICE.md) - -**Tone:** -- Calm, clear, direct -- No corporate jargon -- No startup hype -- No gamer slang - -**Examples:** - -✅ "Profile created." -❌ "Awesome! Your profile is ready to rock!" - -✅ "League joined." -❌ "Congratulations! You're now part of an amazing community!" - -✅ "Results imported." -❌ "Success! We've synced your race data!" - ---- - -## Architectural Decisions - -### ADR-001: In-Memory Adapters for Alpha - -**Decision:** Use in-memory repositories, not database, for alpha. - -**Rationale:** -- Validates domain logic without infrastructure complexity -- Faster iteration on business rules -- Clean Architecture principles: domain independent of persistence -- Production migration only touches infrastructure layer - -**Trade-offs:** -- Data lost on restart (acceptable for alpha testing) -- No concurrent user support (single-user alpha) -- Limited data volume testing (acceptable for workflows) - ---- - -### ADR-002: Mode System vs Feature Flags - -**Decision:** Use environment-based mode system (`GRIDPILOT_MODE=alpha`), not feature flags. - -**Rationale:** -- Clear separation: landing vs alpha vs production -- No flag management complexity -- Environment-appropriate defaults -- Aligns with existing `pre-launch`/`post-launch` pattern - -**Trade-offs:** -- Requires redeployment to switch modes (acceptable for alpha) -- No gradual rollout (not needed for internal alpha) - ---- - -### ADR-003: Manual Result Import for Alpha - -**Decision:** Admin uploads CSV, no iRacing API integration in alpha. - -**Rationale:** -- Validates result processing pipeline -- Avoids iRacing API rate limits during testing -- Simpler alpha scope -- CSV upload pattern works for production fallback - -**Trade-offs:** -- Manual admin step (acceptable for alpha) -- No live result sync (deferred to production) - ---- - -### ADR-004: Single League Per Driver for Alpha - -**Decision:** Drivers join one league maximum in alpha. - -**Rationale:** -- Simplifies UI flows -- Reduces data model complexity -- Validates core competition workflow -- Multi-league requires persistent identity (production concern) - -**Trade-offs:** -- Cannot test cross-league scenarios (acceptable for alpha) -- Multi-league profile in production - ---- - -### ADR-005: No Authentication in Alpha - -**Decision:** No login, no sessions, no permissions. - -**Rationale:** -- In-memory data has no persistent identity -- Auth complexity distracts from workflow testing -- Production auth requires database anyway -- Alpha tests business logic, not security - -**Trade-offs:** -- Cannot test multi-user scenarios (acceptable for alpha) -- Auth added in production with database - ---- - -## Summary - -**Alpha proves:** Automation + unified management reduces admin workload and consolidates fragmented tools. - -**Technical approach:** Production domain/application logic with in-memory infrastructure. - -**Core features:** Driver profiles, league management, race scheduling, result import, standings calculation. - -**Success criteria:** Clean Architecture validated, workflows functional, companion integration working. - -**Next step:** Production migration swaps only infrastructure adapters; domain and UI unchanged. - -**Timeline:** 10 weeks to functional alpha demonstrating value proposition. \ No newline at end of file diff --git a/docs/TESTING_LAYERS.md b/docs/TESTING_LAYERS.md new file mode 100644 index 000000000..c79ce7849 --- /dev/null +++ b/docs/TESTING_LAYERS.md @@ -0,0 +1,82 @@ +# Testing layers between unit and integration + +## Unit tests +Test a single function or class in isolation. + +- No real dependencies +- Everything external is mocked or stubbed +- Very fast, very granular +- Good for pure logic + +**Goal:** Verify correctness of small, isolated units + +--- + +## Sociable unit tests +Units are tested together with their close collaborators. + +- Multiple classes/functions work together +- Only external side effects are mocked (DB, network, filesystem) +- Internal collaborators are real +- Still fast, but more meaningful than isolated units + +**Goal:** Verify local collaboration without infrastructure + +--- + +## Component / Module tests +Test a coherent group of units as one component. + +- Entire module is tested as a black box +- Internal structure is irrelevant +- Boundaries are mocked, internals are real +- No real infrastructure + +**Goal:** Verify that a module behaves correctly as a whole + +--- + +## Service / Slice tests +Test a full use case or service flow. + +- Domain + application logic is real +- Adapters are mocked or faked +- Represents a real business action +- Often maps to one command/query + +**Goal:** Verify business behavior without infrastructure cost + +--- + +## Contract tests +Test the contract between two components or services. + +- Focus on inputs/outputs and schemas +- No real integration required +- One side is mocked, contract is enforced +- Prevents breaking changes + +**Goal:** Verify that boundaries remain compatible + +--- + +## Integration tests +Test real integration with infrastructure. + +- Real database, filesystem, network, etc. +- Slower and more expensive +- Fewer tests, higher confidence + +**Goal:** Verify that the system works with real dependencies + +--- + +## End-to-end (E2E) tests +Test the system exactly like a user would. + +- Everything is real +- UI → backend → infrastructure +- Slow and fragile +- Very few tests should exist + +**Goal:** Verify critical user flows \ No newline at end of file diff --git a/docs/league/actor-and-permissions.md b/docs/league/actor-and-permissions.md deleted file mode 100644 index 1a14d5cb6..000000000 --- a/docs/league/actor-and-permissions.md +++ /dev/null @@ -1,78 +0,0 @@ -# League Actor Model & Permissions (Canonical) - -This document defines the canonical backend actor model and the permission rules for **league admin/owner** operations. - -It is the source of truth for Subtask 0A in [`plans/league-admin-mvp-plan.md`](plans/league-admin-mvp-plan.md:1). - ---- - -## Session identity (source of truth) - -### What the authenticated session contains -- The API authentication layer attaches `request.user.userId` based on the session cookie (`gp_session`). - - See [`AuthenticationGuard.canActivate()`](apps/api/src/domain/auth/AuthenticationGuard.ts:16). -- The backend uses an async request context (`AsyncLocalStorage`) to make the current request available to services. - - See [`requestContextMiddleware()`](adapters/http/RequestContext.ts:24). - - Wired globally for the API via [`AppModule.configure()`](apps/api/src/app.module.ts:49). - -### Mapping: `userId` → `driverId` -Current canonical mapping (for MVP): -- The “actor” is derived from session, and `driverId === userId`. -- This is implemented by [`getActorFromRequestContext()`](apps/api/src/domain/auth/getActorFromRequestContext.ts:12). - -Rationale: -- The current system uses the session user identity as the same identifier used by racing/league membership repositories (e.g. seeded admin user is `driver-1` in session). -- If/when we introduce a real user ↔ driver relationship (1:N), this function becomes the single authoritative mapping point. - ---- - -## Canonical actor model - -The API’s canonical “actor” is: - -```ts -type Actor = { userId: string; driverId: string }; -``` - -Returned by [`getActorFromRequestContext()`](apps/api/src/domain/auth/getActorFromRequestContext.ts:12). - -Rules: -- All auth/permissions decisions use the actor derived from the authenticated session. -- Controllers and services must never use request-body “performer/admin IDs” for authorization decisions. - ---- - -## League permissions: admin/owner - -### Meaning of “league admin/owner” -A driver is authorized as a league admin if: -- They have an **active** membership in the league, and -- Their membership role is either `owner` or `admin`. - -Authoritative check: -- Implemented in the core use case [`GetLeagueAdminPermissionsUseCase.execute()`](core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts:39) by loading membership and validating `status` + `role`. - -### How it is validated server-side -Canonical enforcement entrypoint (API layer): -- [`requireLeagueAdminOrOwner()`](apps/api/src/domain/league/LeagueAuthorization.ts:15) - -This helper: -- Derives the actor from session via [`getActorFromRequestContext()`](apps/api/src/domain/auth/getActorFromRequestContext.ts:12) -- Invokes the core use case with `performerDriverId: actor.driverId` - ---- - -## Contract rule (non-negotiable) - -**No league write operation may accept performer/admin IDs for auth decisions.** - -Concretely: -- Request DTOs may still temporarily contain IDs for “target entities” (e.g. `targetDriverId`), but never the acting user/admin/performer ID. -- Any endpoint/service that needs “who is performing this” MUST obtain it from session-derived actor, not from request payload, params, or hardcoded values. - -Tests: -- Actor derives from session, not payload: [`ActorFromSession`](apps/api/src/domain/auth/ActorFromSession.test.ts:17). -- Permission helper uses session-derived actor consistently: [`ActorFromSession`](apps/api/src/domain/auth/ActorFromSession.test.ts:30). -- Example application in a league write-like operation (`joinLeague`) ignores payload driverId and uses session actor: [`LeagueService`](apps/api/src/domain/league/LeagueService.test.ts:1). - ---- \ No newline at end of file diff --git a/docs/standings-analysis.md b/docs/standings-analysis.md deleted file mode 100644 index 52c0fc62d..000000000 --- a/docs/standings-analysis.md +++ /dev/null @@ -1,273 +0,0 @@ -# League Standings Calculation Analysis - -## Overview - -This document analyzes the standings calculation logic for the GridPilot leagues feature. The system has two different standings entities: - -1. **Standing** (core/racing/domain/entities/Standing.ts) - League-level standings -2. **ChampionshipStanding** (core/racing/domain/entities/championship/ChampionshipStanding.ts) - Season-level standings - -## Architecture Overview - -### Data Flow - -``` -Race Completion/Result Import - ↓ -CompleteRaceUseCase / ImportRaceResultsUseCase - ↓ -StandingRepository.recalculate() or StandingRepository.save() - ↓ -Standing Entity (League-level) - ↓ -GetLeagueStandingsUseCase - ↓ -LeagueStandingsPresenter - ↓ -API Response -``` - -### Season-Level Standings Flow - -``` -Season Completion - ↓ -RecalculateChampionshipStandingsUseCase - ↓ -ChampionshipStanding Entity (Season-level) - ↓ -ChampionshipStandingRepository -``` - -## Identified Issues - -### Issue 1: InMemoryStandingRepository.recalculate() Doesn't Consider Seasons - -**Location:** `adapters/racing/persistence/inmemory/InMemoryStandingRepository.ts:169-270` - -**Problem:** The `recalculate()` method calculates standings from ALL completed races in a league, not just races from a specific season. - -```typescript -async recalculate(leagueId: string): Promise { - // ... - const races = await this.raceRepository.findCompletedByLeagueId(leagueId); - // ... -} -``` - -**Impact:** When standings are recalculated, they include results from all seasons, not just the current/active season. This means: -- Historical season results are mixed with current season results -- Standings don't accurately reflect a specific season's performance -- Drop score policies can't be applied correctly per season - -**Expected Behavior:** Standings should be calculated per season, considering only races from that specific season. - -### Issue 2: CompleteRaceUseCase Uses Hardcoded Points System - -**Location:** `core/racing/application/use-cases/CompleteRaceUseCase.ts:200-203` - -**Problem:** The `updateStandings()` method uses a hardcoded F1 points system instead of the league's configured points system. - -```typescript -standing = standing.addRaceResult(result.position.toNumber(), { - 1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1 -}); -``` - -**Impact:** -- All leagues use the same points system regardless of their configuration -- Custom points systems are ignored -- IndyCar and other racing series points systems are not applied - -**Expected Behavior:** The points system should be retrieved from the league's configuration and applied accordingly. - -### Issue 3: ImportRaceResultsUseCase Recalculates All League Standings - -**Location:** `core/racing/application/use-cases/ImportRaceResultsUseCase.ts:156` - -**Problem:** When importing race results, the method calls `standingRepository.recalculate(league.id.toString())` which recalculates ALL standings for the league. - -```typescript -await this.standingRepository.recalculate(league.id.toString()); -``` - -**Impact:** -- Performance issue: Recalculating all standings when only one race is imported -- Standings include results from all seasons, not just the current season -- Inefficient for large leagues with many races - -**Expected Behavior:** Only recalculate standings for the specific season that the race belongs to. - -### Issue 4: Standing Entity Doesn't Track Season Association - -**Location:** `core/racing/domain/entities/Standing.ts` - -**Problem:** The `Standing` entity only tracks `leagueId` and `driverId`, but not `seasonId`. - -```typescript -export class Standing extends Entity { - readonly leagueId: LeagueId; - readonly driverId: DriverId; - // No seasonId field -} -``` - -**Impact:** -- Standings are stored per league, not per season -- Can't distinguish between standings from different seasons -- Can't apply season-specific drop score policies - -**Expected Behavior:** Standing should include a `seasonId` field to track which season the standings belong to. - -### Issue 5: GetLeagueStandingsUseCase Retrieves League-Level Standings - -**Location:** `core/racing/application/use-cases/GetLeagueStandingsUseCase.ts:39` - -**Problem:** The use case retrieves standings from `StandingRepository.findByLeagueId()`, which returns league-level standings. - -```typescript -const standings = await this.standingRepository.findByLeagueId(input.leagueId); -``` - -**Impact:** -- Returns standings that include results from all seasons -- Doesn't provide season-specific standings -- Can't apply season-specific drop score policies - -**Expected Behavior:** The use case should retrieve season-specific standings (ChampionshipStanding) or accept a seasonId parameter. - -### Issue 6: LeagueStandingsRepository is Not Connected to Standing Calculation - -**Location:** `adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository.ts` - -**Problem:** The `LeagueStandingsRepository` interface is defined but not connected to the standing calculation logic. - -```typescript -export interface LeagueStandingsRepository { - getLeagueStandings(leagueId: string): Promise; -} -``` - -**Impact:** -- The repository is not used by any standing calculation use case -- It's only used for storing pre-calculated standings -- No clear connection between standing calculation and this repository - -**Expected Behavior:** The repository should be used to store and retrieve season-specific standings. - -### Issue 7: LeagueStandingsPresenter Hardcodes Metrics - -**Location:** `apps/api/src/domain/league/presenters/LeagueStandingsPresenter.ts:24-30` - -**Problem:** The presenter hardcodes wins, podiums, and races metrics to 0. - -```typescript -wins: 0, -podiums: 0, -races: 0, -``` - -**Impact:** -- API always returns 0 for these metrics -- Users can't see actual win/podium/race counts -- TODO comment indicates this is a known issue - -**Expected Behavior:** These metrics should be calculated and returned from the standing entity. - -## Root Cause Analysis - -The core issue is a **design inconsistency** between two different standings systems: - -1. **League-level standings** (`Standing` entity): - - Stored per league (not per season) - - Updated immediately when races complete - - Used by `GetLeagueStandingsUseCase` - - Don't support season-specific features - -2. **Season-level standings** (`ChampionshipStanding` entity): - - Stored per season - - Calculated using `RecalculateChampionshipStandingsUseCase` - - Support drop score policies per season - - Not used by the main standings API - -The system has **two parallel standings calculation paths** that don't align: -- `CompleteRaceUseCase` and `ImportRaceResultsUseCase` update league-level standings -- `RecalculateChampionshipStandingsUseCase` calculates season-level standings -- `GetLeagueStandingsUseCase` retrieves league-level standings - -## Recommendations - -### Short-term Fixes - -1. **Fix CompleteRaceUseCase to use league's points system** - - Retrieve points system from league configuration - - Apply correct points based on league settings - -2. **Fix ImportRaceResultsUseCase to recalculate only relevant standings** - - Pass seasonId to recalculate method - - Only recalculate standings for the specific season - -3. **Add seasonId to Standing entity** - - Modify Standing entity to include seasonId - - Update all repositories and use cases to handle seasonId - -4. **Fix GetLeagueStandingsUseCase to accept seasonId parameter** - - Add seasonId parameter to input - - Filter standings by seasonId - -### Long-term Refactoring - -1. **Consolidate standings into single system** - - Merge Standing and ChampionshipStanding entities - - Use ChampionshipStanding as the primary standings entity - - Remove league-level standings - -2. **Update CompleteRaceUseCase to use RecalculateChampionshipStandingsUseCase** - - Instead of updating standings immediately, trigger recalculation - - Ensure season-specific calculations - -3. **Update ImportRaceResultsUseCase to use RecalculateChampionshipStandingsUseCase** - - Instead of calling StandingRepository.recalculate(), call RecalculateChampionshipStandingsUseCase - - Ensure season-specific calculations - -4. **Update GetLeagueStandingsUseCase to retrieve ChampionshipStanding** - - Change to retrieve season-specific standings - - Add seasonId parameter to input - -5. **Remove LeagueStandingsRepository** - - This repository is not connected to standing calculation - - Use ChampionshipStandingRepository instead - -6. **Fix LeagueStandingsPresenter to return actual metrics** - - Calculate wins, podiums, and races from standing entity - - Remove hardcoded values - -## Testing Strategy - -### Unit Tests -- Test CompleteRaceUseCase with different points systems -- Test ImportRaceResultsUseCase with season-specific recalculation -- Test GetLeagueStandingsUseCase with seasonId parameter -- Test Standing entity with seasonId field - -### Integration Tests -- Test complete race flow with season-specific standings -- Test import race results flow with season-specific standings -- Test standings recalculation for specific season -- Test API endpoints with season-specific standings - -### End-to-End Tests -- Test league standings API with season parameter -- Test standings calculation across multiple seasons -- Test drop score policy application per season - -## Conclusion - -The standings calculation system has significant design issues that prevent accurate season-specific standings. The main problems are: - -1. League-level standings don't track seasons -2. Points systems are hardcoded in some places -3. Standings recalculation includes all seasons -4. Two parallel standings systems don't align - -Fixing these issues requires both short-term fixes and long-term refactoring to consolidate the standings system into a single, season-aware design. diff --git a/docs/standings-fixes-summary.md b/docs/standings-fixes-summary.md deleted file mode 100644 index 15249acb4..000000000 --- a/docs/standings-fixes-summary.md +++ /dev/null @@ -1,101 +0,0 @@ -# Standings Calculation Fixes - Summary - -## Overview -Fixed multiple issues in the GridPilot leagues feature's standings calculation logic using a TDD approach. - -## Issues Fixed - -### 1. CompleteRaceUseCase - Hardcoded Points System -**Problem**: Used hardcoded F1 points (25, 18, 15...) instead of league's configured points system -**Impact**: All leagues used same points regardless of configuration (F1, IndyCar, custom) -**Fix**: Now uses league's points system from `LeagueScoringConfig` - -### 2. ImportRaceResultsUseCase - Recalculates All League Standings -**Problem**: When importing race results, recalculated ALL standings for the league -**Impact**: Performance issue + included results from all seasons -**Fix**: Now only recalculates standings for the specific season - -### 3. GetLeagueStandingsUseCase - Retrieves League-Level Standings -**Problem**: Retrieved standings that included results from all seasons -**Impact**: Returns inaccurate standings for any specific season -**Fix**: Now accepts `seasonId` parameter and returns season-specific standings - -### 4. LeagueStandingsPresenter - Hardcoded Metrics -**Problem**: Hardcoded wins, podiums, and races metrics to 0 -**Impact**: API always returned 0 for these metrics -**Fix**: Now calculates actual metrics from standings data - -## Test Results - -### Core Tests (All Passing) -``` -✓ core/racing/application/use-cases/CompleteRaceUseCase.test.ts (6 tests) -✓ core/racing/application/use-cases/ImportRaceResultsUseCase.test.ts (6 tests) -✓ core/racing/application/use-cases/GetLeagueStandingsUseCase.test.ts (2 tests) -``` - -**Total**: 14 tests passed in 307ms - -### Full Test Suite -``` -Test Files: 9 failed, 822 passed, 8 skipped (839) -Tests: 9 failed, 4904 passed, 80 skipped (4993) -``` - -**Note**: The 9 failed tests are pre-existing issues unrelated to standings fixes: -- UI test failures in website components -- Formatting issues in sponsorship view models -- Visibility issues in league config integration tests -- Rating issues in team HTTP tests - -## Files Modified - -### Core Domain Layer -- `core/racing/application/use-cases/CompleteRaceUseCase.ts` -- `core/racing/application/use-cases/ImportRaceResultsUseCase.ts` -- `core/racing/application/use-cases/GetLeagueStandingsUseCase.ts` - -### Test Files -- `core/racing/application/use-cases/CompleteRaceUseCase.test.ts` -- `core/racing/application/use-cases/ImportRaceResultsUseCase.test.ts` -- `core/racing/application/use-cases/GetLeagueStandingsUseCase.test.ts` - -### API Layer -- `apps/api/src/domain/league/presenters/LeagueStandingsPresenter.ts` -- `apps/api/src/domain/race/RaceProviders.ts` - -## Key Improvements - -1. **Accurate Standings**: Standings now correctly reflect season-specific performance -2. **Flexible Points Systems**: Leagues can use F1, IndyCar, or custom points systems -3. **Better Performance**: Only recalculates relevant standings instead of all standings -4. **Complete Metrics**: API now returns accurate win, podium, and race counts -5. **Season-Aware**: All standings calculations now consider the specific season - -## Implementation Approach - -Used Test-Driven Development (TDD): -1. Wrote failing tests that expected the new behavior -2. Implemented fixes to make tests pass -3. Verified all tests pass successfully - -## Verification - -All core tests pass successfully: -```bash -npm test -- --run ./core/racing/application/use-cases/CompleteRaceUseCase.test.ts \ - ./core/racing/application/use-cases/ImportRaceResultsUseCase.test.ts \ - ./core/racing/application/use-cases/GetLeagueStandingsUseCase.test.ts -``` - -Result: 14 tests passed in 307ms - -## Conclusion - -The fixes successfully address the standings calculation issues. All new tests pass, and the changes are backward compatible. The implementation now correctly handles: -- League-specific points systems -- Season-specific standings -- Accurate metrics calculation -- Performance optimization - -The 4 failing tests in the full suite are pre-existing issues unrelated to the standings fixes. diff --git a/docs/standings-issues-summary.md b/docs/standings-issues-summary.md deleted file mode 100644 index 27bac2f8d..000000000 --- a/docs/standings-issues-summary.md +++ /dev/null @@ -1,161 +0,0 @@ -# League Standings Issues Summary - -## Quick Overview - -The leagues feature's standings calculation has **7 critical issues** that prevent accurate season-specific standings. - -## Issues Found - -### 1. ❌ InMemoryStandingRepository.recalculate() Doesn't Consider Seasons -**File:** `adapters/racing/persistence/inmemory/InMemoryStandingRepository.ts:169-270` - -**Problem:** Calculates standings from ALL completed races in a league, not just races from a specific season. - -**Impact:** Standings mix results from all seasons, making them inaccurate for any specific season. - ---- - -### 2. ❌ CompleteRaceUseCase Uses Hardcoded Points System -**File:** `core/racing/application/use-cases/CompleteRaceUseCase.ts:200-203` - -**Problem:** Uses hardcoded F1 points system instead of league's configured points system. - -**Impact:** All leagues use same points system regardless of configuration (F1, IndyCar, custom). - ---- - -### 3. ❌ ImportRaceResultsUseCase Recalculates All League Standings -**File:** `core/racing/application/use-cases/ImportRaceResultsUseCase.ts:156` - -**Problem:** Recalculates ALL standings for the league when importing one race. - -**Impact:** Performance issue + includes results from all seasons. - ---- - -### 4. ❌ Standing Entity Doesn't Track Season Association -**File:** `core/racing/domain/entities/Standing.ts` - -**Problem:** Standing entity only tracks `leagueId` and `driverId`, no `seasonId`. - -**Impact:** Can't distinguish between standings from different seasons. - ---- - -### 5. ❌ GetLeagueStandingsUseCase Retrieves League-Level Standings -**File:** `core/racing/application/use-cases/GetLeagueStandingsUseCase.ts:39` - -**Problem:** Retrieves standings that include results from all seasons. - -**Impact:** Returns inaccurate standings for any specific season. - ---- - -### 6. ❌ LeagueStandingsRepository Not Connected to Standing Calculation -**File:** `adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository.ts` - -**Problem:** Repository exists but not used by standing calculation logic. - -**Impact:** No clear connection between standing calculation and this repository. - ---- - -### 7. ❌ LeagueStandingsPresenter Hardcodes Metrics -**File:** `apps/api/src/domain/league/presenters/LeagueStandingsPresenter.ts:24-30` - -**Problem:** Hardcodes wins, podiums, and races to 0. - -**Impact:** API always returns 0 for these metrics. - ---- - -## Root Cause - -**Design Inconsistency:** Two parallel standings systems that don't align: - -1. **League-level standings** (`Standing` entity): - - Updated immediately when races complete - - Used by `GetLeagueStandingsUseCase` - - Don't support season-specific features - -2. **Season-level standings** (`ChampionshipStanding` entity): - - Calculated using `RecalculateChampionshipStandingsUseCase` - - Support drop score policies per season - - Not used by main standings API - ---- - -## Recommended Fixes - -### Short-term (Quick Wins) -1. ✅ Fix CompleteRaceUseCase to use league's points system -2. ✅ Fix ImportRaceResultsUseCase to recalculate only relevant standings -3. ✅ Add seasonId to Standing entity -4. ✅ Fix GetLeagueStandingsUseCase to accept seasonId parameter - -### Long-term (Refactoring) -1. ✅ Consolidate standings into single system (use ChampionshipStanding) -2. ✅ Update CompleteRaceUseCase to use RecalculateChampionshipStandingsUseCase -3. ✅ Update ImportRaceResultsUseCase to use RecalculateChampionshipStandingsUseCase -4. ✅ Update GetLeagueStandingsUseCase to retrieve ChampionshipStanding -5. ✅ Remove LeagueStandingsRepository -6. ✅ Fix LeagueStandingsPresenter to return actual metrics - ---- - -## Testing Strategy - -### Unit Tests -- Test CompleteRaceUseCase with different points systems -- Test ImportRaceResultsUseCase with season-specific recalculation -- Test GetLeagueStandingsUseCase with seasonId parameter -- Test Standing entity with seasonId field - -### Integration Tests -- Test complete race flow with season-specific standings -- Test import race results flow with season-specific standings -- Test standings recalculation for specific season -- Test API endpoints with season-specific standings - -### End-to-End Tests -- Test league standings API with season parameter -- Test standings calculation across multiple seasons -- Test drop score policy application per season - ---- - -## Files to Modify - -### Core Domain -- `core/racing/domain/entities/Standing.ts` - Add seasonId field -- `core/racing/domain/repositories/StandingRepository.ts` - Update interface - -### Application Layer -- `core/racing/application/use-cases/CompleteRaceUseCase.ts` - Use league points system -- `core/racing/application/use-cases/ImportRaceResultsUseCase.ts` - Season-specific recalculation -- `core/racing/application/use-cases/GetLeagueStandingsUseCase.ts` - Accept seasonId parameter - -### Persistence Layer -- `adapters/racing/persistence/inmemory/InMemoryStandingRepository.ts` - Update recalculate method -- `adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository.ts` - Remove or refactor - -### API Layer -- `apps/api/src/domain/league/presenters/LeagueStandingsPresenter.ts` - Return actual metrics - ---- - -## Priority - -**HIGH:** Issues 1, 2, 4, 5 (affect accuracy of standings) -**MEDIUM:** Issues 3, 6 (affect performance and architecture) -**LOW:** Issue 7 (affects API response quality) - ---- - -## Next Steps - -1. Create implementation plan for short-term fixes -2. Design long-term refactoring strategy -3. Update tests to cover season-specific scenarios -4. Implement fixes incrementally -5. Verify standings accuracy after each fix